From 586f48cb6803c1749f89def8b2e1130deda3bb4f Mon Sep 17 00:00:00 2001 From: Roman Khadka <18639263+romankhadka@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:34 -0600 Subject: [PATCH 1/3] fix(coding-agent): pause a stalled goal instead of looping continuations A goal whose continuations repeatedly end without a tool call or new user message cannot progress by re-presenting the same context. After three consecutive stalled continuation windows the goal now pauses as waiting for user input, and it resumes automatically on the next user prompt. An explicit /goal pause still requires /goal resume. fixes #986 --- .../coding-agent/docs/long-running-agents.md | 2 + .../coding-agent/src/core/agent-session.ts | 36 +++- packages/coding-agent/src/core/goals.ts | 64 +++++- .../986-goal-continuation-loop.test.ts | 201 ++++++++++++++++++ 4 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts diff --git a/packages/coding-agent/docs/long-running-agents.md b/packages/coding-agent/docs/long-running-agents.md index 2ec0a36e9..488635f36 100644 --- a/packages/coding-agent/docs/long-running-agents.md +++ b/packages/coding-agent/docs/long-running-agents.md @@ -196,6 +196,8 @@ await goal.complete() Goal state records token usage, elapsed time, continuation count, and an optional explicit token budget. The harness keeps prompting an active goal after ordinary assistant turns; only `goal.complete()` marks successful completion. Creating a persistent goal is an explicit user or host action, not something the agent should infer from every task. +Continuations stop when they can no longer make progress. When consecutive goal continuations end without a tool call or a new user message, the goal pauses as waiting for user input instead of re-presenting the same context. A goal paused this way resumes automatically on the next user prompt; an explicit `/goal pause` stays paused until `/goal resume`. + ## Autonomous Mode Autonomous mode is a bounded host policy for runs where no human input is expected. Prime Agent adds follow-up continuations until configured quality gates pass or a continuation, turn, token, or wall-clock limit is reached. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e99462eab..c33a28983 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -162,6 +162,7 @@ import { type GoalHostResponse, type GoalState, type GoalStatus, + goalContinuationIsStalled, goalHostResponse, goalTokenDeltaForUsage, isPersistedGoalState, @@ -1804,7 +1805,7 @@ export class AgentSession { this._setGoalState(emptyGoalState()); } - private _pauseGoal(reason = "Paused by user"): void { + private _pauseGoal(reason = "Paused by user", options: { waitingForUser?: boolean } = {}): void { this._clearQueuedGoalContexts(); if (this._goalState.status !== "active") { this._emitGoalUpdate(); @@ -1815,11 +1816,31 @@ export class AgentSession { ...goal, active: false, status: "paused", + waitingForUser: options.waitingForUser === true ? true : undefined, lastReason: reason, lastError: undefined, }); } + /** + * Reactivate a goal that auto-paused waiting for user input. The prompt + * being admitted supplies that input; the regular continuation hook takes + * over after the turn, so no goal context is injected here. An explicit + * `/goal pause` does not set `waitingForUser` and is not resumed. + */ + private _resumeGoalForUserInput(): void { + if (this._goalState.status !== "paused" || this._goalState.waitingForUser !== true) { + return; + } + this._setGoalState({ + ...this._goalState, + active: true, + status: "active", + lastReason: undefined, + lastError: undefined, + }); + } + private async _resumeGoal(): Promise { if (!this._goalState.objective) { this._emitGoalUpdate(); @@ -3171,6 +3192,12 @@ export class AgentSession { return []; } try { + if (goalContinuationIsStalled(context.newMessages)) { + this._pauseGoal("Waiting for user input: goal continuations repeatedly ended without tool calls", { + waitingForUser: true, + }); + return []; + } this._ensureGoalRuntimeActive(context.context); const nextGoal = { ...this._goalState, @@ -4665,6 +4692,13 @@ export class AgentSession { return; } + // A genuine user prompt supplies the input a stall-paused goal was + // waiting for. Host-generated prompts and custom-message deliveries + // (heartbeats, agent messages) do not resume it. + if (!isInternalPrompt && !options?.customMessage) { + this._resumeGoalForUserInput(); + } + const queueForStreaming = this.isStreaming; const queueForBusy = options?.queueIfBusy === true && this._isBusyForSessionInput("preflight"); const visibleQueued = queueForStreaming || queueForBusy; diff --git a/packages/coding-agent/src/core/goals.ts b/packages/coding-agent/src/core/goals.ts index dca8b391d..0c3ea05df 100644 --- a/packages/coding-agent/src/core/goals.ts +++ b/packages/coding-agent/src/core/goals.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { CustomMessage } from "./messages.js"; @@ -7,6 +8,15 @@ export const GOAL_CONTEXT_PREVIEW_LABEL = "Goal context"; export const GOAL_SKILL_NAME = "goal"; export const MAX_THREAD_GOAL_OBJECTIVE_CHARS = 4000; +/** + * Number of consecutive goal continuations that may end without a tool call + * or new user message before the goal pauses as waiting for user input. + * Below this the continuation is a legitimate nudge; at this count the model + * has repeatedly declined to act and re-injecting the same context cannot + * make progress. + */ +export const MAX_STALLED_GOAL_CONTINUATIONS = 3; + export type GoalStatus = "idle" | "active" | "paused" | "budget_limited" | "complete" | "error"; export type GoalContextKind = "continuation" | "budget_limit" | "objective_updated"; @@ -23,6 +33,12 @@ export interface GoalState { updatedAt?: number; lastReason?: string; lastError?: string; + /** + * Set when the host paused the goal because continuations stalled. A goal + * paused this way resumes automatically on the next user prompt, unlike an + * explicit `/goal pause`. + */ + waitingForUser?: boolean; } /** Goal payload returned to the kernel-side goal skill. Keys are Python-conventional snake_case. */ @@ -69,6 +85,7 @@ export function normalizeGoalState(goal: GoalState): GoalState { tokensUsed: Math.max(0, Math.trunc(goal.tokensUsed)), timeUsedSeconds: Math.max(0, Math.trunc(goal.timeUsedSeconds)), continuationsUsed: Math.max(0, Math.trunc(goal.continuationsUsed)), + waitingForUser: goal.status === "paused" && goal.waitingForUser === true ? true : undefined, }; } @@ -179,6 +196,49 @@ export function createGoalContextMessage( }; } +/** + * Whether injecting another goal continuation into this run would only repeat + * the previous one. True when the run's trailing `MAX_STALLED_GOAL_CONTINUATIONS` + * goal-context windows all ended without a tool call or a user message: the + * model saw the same context that many times and chose no observable action, + * so the goal should pause and wait for user input instead of looping. + */ +export function goalContinuationIsStalled(runMessages: AgentMessage[]): boolean { + let stalledWindows = 0; + let windowHasProgress = false; + // Walk backward; each goal context closes the window of messages after it. + for (let index = runMessages.length - 1; index >= 0; index--) { + const message = runMessages[index]; + if (message.role === "custom" && message.customType === GOAL_CONTEXT_CUSTOM_TYPE) { + if (windowHasProgress) { + break; + } + stalledWindows++; + if (stalledWindows >= MAX_STALLED_GOAL_CONTINUATIONS) { + return true; + } + continue; + } + windowHasProgress ||= goalWindowProgressMessage(message); + } + return false; +} + +/** + * Whether a message counts as progress inside a goal-context window. Tool + * calls are observable work; a user message is new information that can + * unblock the goal. Host-injected custom messages are neither. + */ +function goalWindowProgressMessage(message: AgentMessage): boolean { + if (message.role === "user") { + return true; + } + if (message.role === "assistant") { + return message.content.some((block) => block.type === "toolCall"); + } + return false; +} + export function formatGoalUsage(goal: GoalState): string | undefined { if (goal.tokenBudget !== undefined) { return `${goal.tokensUsed} / ${goal.tokenBudget} tokens`; @@ -226,7 +286,9 @@ The goal persists across turns. Ending one turn does not reduce or redefine the Before marking the goal complete, audit the current state against every requirement in the objective. Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. If the objective is achieved, run \`await goal.complete()\` in ipython so usage accounting is preserved. -Do not call \`goal.complete()\` unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work.`; +Do not call \`goal.complete()\` unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. + +If you cannot make progress without new user input (a required approval, credential, or answer), state what you are waiting for and end the turn without tool calls. After repeated turns with no tool calls the goal pauses automatically and resumes with the next user message.`; } function budgetLimitPrompt(goal: GoalState): string { diff --git a/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts b/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts new file mode 100644 index 000000000..9f4d3857e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts @@ -0,0 +1,201 @@ +import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentSession } from "../../../src/core/agent-session.js"; +import { goalContinuationIsStalled } from "../../../src/core/goals.js"; +import { createHarness, getAssistantTexts, type Harness } from "../harness.js"; + +/** + * Regression for https://github.com/PrimeIntellect-ai/prime-agent/issues/986. + * + * A goal whose continuations repeatedly produce no tool calls (the model is + * blocked on user approval, credentials, or an unanswered question) must pause + * as waiting for user input instead of injecting identical goal contexts + * forever, and must resume when the user actually replies. + */ + +/** + * Stand-in for the real ipython tool. `goal.*` cells are dispatched to the + * session's goal host-request handler, mirroring the kernel comm bridge; + * any other cell is a plain successful execution. + */ +function createFauxIpythonTool(sessionRef: { current?: AgentSession }): AgentTool { + return { + name: "ipython", + label: "ipython", + description: "Execute Python code in the agent kernel.", + parameters: Type.Object({ code: Type.String() }), + execute: async (_toolCallId, params) => { + const session = sessionRef.current; + if (!session) { + throw new Error("test session is not initialized"); + } + const code = (params as { code: string }).code.trim(); + let text = ""; + if (code.startsWith("goal.")) { + text = JSON.stringify(session.handleGoalHostRequest(code, {})); + } + return { + content: [{ type: "text", text }], + details: {}, + }; + }, + }; +} + +function goalContextMessages(harness: Harness) { + return harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === "goal_context", + ); +} + +function goalContextMessage(): AgentMessage { + return { + role: "custom", + customType: "goal_context", + content: "continue", + display: true, + timestamp: 0, + }; +} + +describe("regression #986: goal continuation loop", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + async function createGoalHarness(options: { initialGoal?: { objective: string } } = {}): Promise { + const sessionRef: { current?: AgentSession } = {}; + const harness = await createHarness({ + tools: [createFauxIpythonTool(sessionRef)], + initialGoal: options.initialGoal, + }); + sessionRef.current = harness.session; + harnesses.push(harness); + return harness; + } + + it("pauses the goal as waiting for user input after repeated continuations without tool calls", async () => { + const harness = await createGoalHarness(); + harness.setResponses([ + fauxAssistantMessage("Waiting for the sandbox key."), + fauxAssistantMessage("State unchanged: still waiting for the sandbox key."), + fauxAssistantMessage("State unchanged: still waiting for the sandbox key."), + ]); + + await harness.session.prompt("/goal run the evidence harness once the sandbox key arrives"); + + expect(harness.session.goalState).toMatchObject({ + active: false, + status: "paused", + waitingForUser: true, + continuationsUsed: 2, + }); + expect(harness.session.goalState.lastReason).toContain("Waiting for user input"); + // Initial context plus exactly two continuations; the loop must not run on. + expect(goalContextMessages(harness)).toHaveLength(3); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("resets the stall window when a continuation turn makes a tool call", async () => { + const harness = await createGoalHarness(); + harness.setResponses([ + fauxAssistantMessage("Thinking about the first step."), + fauxAssistantMessage("Still thinking."), + fauxAssistantMessage(fauxToolCall("ipython", { code: "print('working')" }), { stopReason: "toolUse" }), + fauxAssistantMessage("Ran the script; waiting for review."), + fauxAssistantMessage("State unchanged: waiting for review."), + fauxAssistantMessage("State unchanged: waiting for review."), + fauxAssistantMessage("State unchanged: waiting for review."), + ]); + + await harness.session.prompt("/goal run the script and wait for review"); + + expect(harness.session.goalState).toMatchObject({ + status: "paused", + waitingForUser: true, + }); + // The tool-call turn resets the stall count, so three more toolless + // continuations are needed before the goal pauses again. + expect(goalContextMessages(harness)).toHaveLength(6); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("resumes a stall-paused goal on the next user prompt and keeps continuing", async () => { + const harness = await createGoalHarness(); + harness.setResponses([ + fauxAssistantMessage("Waiting for approval."), + fauxAssistantMessage("Still waiting for approval."), + fauxAssistantMessage("Still waiting for approval."), + ]); + await harness.session.prompt("/goal ship the release after approval"); + expect(harness.session.goalState).toMatchObject({ status: "paused", waitingForUser: true }); + + harness.appendResponses([ + fauxAssistantMessage("Approval received, shipping."), + fauxAssistantMessage(fauxToolCall("ipython", { code: "goal.complete" }), { stopReason: "toolUse" }), + fauxAssistantMessage("Shipped."), + ]); + + await harness.session.prompt("Approved, go ahead."); + + expect(harness.session.goalState).toMatchObject({ + active: false, + status: "complete", + lastReason: "Goal achieved", + }); + // The user prompt reactivated continuations: one more goal context was + // injected after the reply before the model completed the goal. + expect(goalContextMessages(harness)).toHaveLength(4); + const statusHistory = harness.eventsOfType("goal_update").map((event) => event.goal.status); + expect(statusHistory.indexOf("paused")).toBeGreaterThanOrEqual(0); + expect(statusHistory.lastIndexOf("active")).toBeGreaterThan(statusHistory.indexOf("paused")); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("does not resume an explicitly paused goal on a user prompt", async () => { + const harness = await createGoalHarness({ initialGoal: { objective: "hold until told otherwise" } }); + await harness.session.prompt("/goal pause"); + expect(harness.session.goalState).toMatchObject({ status: "paused" }); + expect(harness.session.goalState.waitingForUser).toBeUndefined(); + + harness.setResponses([fauxAssistantMessage("Hello!")]); + await harness.session.prompt("Hi there."); + + expect(harness.session.goalState).toMatchObject({ status: "paused" }); + expect(goalContextMessages(harness)).toHaveLength(0); + expect(getAssistantTexts(harness)).toEqual(["Hello!"]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("detects stalled continuation windows only when they are consecutive and trailing", () => { + const text = () => fauxAssistantMessage("no progress"); + const toolCall = () => + fauxAssistantMessage(fauxToolCall("ipython", { code: "print('x')" }), { stopReason: "toolUse" }); + const userReply: AgentMessage = { role: "user", content: "new information", timestamp: 0 }; + + const threeStalled = [goalContextMessage(), text(), goalContextMessage(), text(), goalContextMessage(), text()]; + expect(goalContinuationIsStalled(threeStalled)).toBe(true); + + const twoStalled = threeStalled.slice(2); + expect(goalContinuationIsStalled(twoStalled)).toBe(false); + + const userInLastWindow = [...threeStalled, userReply]; + expect(goalContinuationIsStalled(userInLastWindow)).toBe(false); + + const toolCallInMiddleWindow = [ + goalContextMessage(), + text(), + goalContextMessage(), + toolCall(), + goalContextMessage(), + text(), + ]; + expect(goalContinuationIsStalled(toolCallInMiddleWindow)).toBe(false); + }); +}); From b24083bb62b878d267448b4efed2c013c0a13d4b Mon Sep 17 00:00:00 2001 From: Roman Khadka <18639263+romankhadka@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:25:23 -0600 Subject: [PATCH 2/3] docs(coding-agent): add changelog entry for the goal stall pause --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579..6a8fba3a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) +- Fixed Goal Mode injecting identical continuations forever when the model was blocked on user input; a stalled goal now pauses as waiting for user input and resumes on the next user prompt ([#1113](https://github.com/PrimeIntellect-ai/prime-agent/pull/1113) by [@romankhadka](https://github.com/romankhadka)). ## [0.7.1] - 2026-08-07 From 513f66a58c62604adbb1028e8bfbb67c286c96e0 Mon Sep 17 00:00:00 2001 From: Roman Khadka <18639263+romankhadka@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:54:09 -0600 Subject: [PATCH 3/3] refactor(coding-agent): simplify goal stall internals and close steer/follow-up resume gap - Replace the waitingForUser flag with pausedBy: "user" | "host" so the pause cause is a single field and normalizeGoalState no longer repairs a cross-field invariant. - Collapse the stall detector's accumulator into direct early returns and absorb the single-use progress helper. - Resume a host-paused goal for user input delivered via steer() and followUp(), which bypass prompt(); previously only prompt() resumed it. - Derive regression-test fixtures from MAX_STALLED_GOAL_CONTINUATIONS and cover the followUp resume path. --- .../coding-agent/src/core/agent-session.ts | 22 ++-- packages/coding-agent/src/core/goals.ts | 48 +++----- .../986-goal-continuation-loop.test.ts | 114 +++++++++++------- 3 files changed, 101 insertions(+), 83 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c33a28983..44ec01b0f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -160,6 +160,7 @@ import { GOAL_SKILL_NAME, GOAL_STATE_CUSTOM_TYPE, type GoalHostResponse, + type GoalPausedBy, type GoalState, type GoalStatus, goalContinuationIsStalled, @@ -1805,7 +1806,7 @@ export class AgentSession { this._setGoalState(emptyGoalState()); } - private _pauseGoal(reason = "Paused by user", options: { waitingForUser?: boolean } = {}): void { + private _pauseGoal(reason = "Paused by user", pausedBy: GoalPausedBy = "user"): void { this._clearQueuedGoalContexts(); if (this._goalState.status !== "active") { this._emitGoalUpdate(); @@ -1816,20 +1817,20 @@ export class AgentSession { ...goal, active: false, status: "paused", - waitingForUser: options.waitingForUser === true ? true : undefined, + pausedBy, lastReason: reason, lastError: undefined, }); } /** - * Reactivate a goal that auto-paused waiting for user input. The prompt - * being admitted supplies that input; the regular continuation hook takes - * over after the turn, so no goal context is injected here. An explicit - * `/goal pause` does not set `waitingForUser` and is not resumed. + * Reactivate a goal the host paused waiting for user input. The user + * message being admitted supplies that input; the regular continuation + * hook takes over after the turn, so no goal context is injected here. + * An explicit `/goal pause` (pausedBy "user") is not resumed. */ private _resumeGoalForUserInput(): void { - if (this._goalState.status !== "paused" || this._goalState.waitingForUser !== true) { + if (this._goalState.pausedBy !== "host") { return; } this._setGoalState({ @@ -1837,7 +1838,6 @@ export class AgentSession { active: true, status: "active", lastReason: undefined, - lastError: undefined, }); } @@ -3193,9 +3193,7 @@ export class AgentSession { } try { if (goalContinuationIsStalled(context.newMessages)) { - this._pauseGoal("Waiting for user input: goal continuations repeatedly ended without tool calls", { - waitingForUser: true, - }); + this._pauseGoal("Waiting for user input: goal continuations repeatedly ended without tool calls", "host"); return []; } this._ensureGoalRuntimeActive(context.context); @@ -4896,6 +4894,7 @@ export class AgentSession { throw new Error("Queued prompt normalization did not produce a prompt"); } + this._resumeGoalForUserInput(); await this._queuePreparedPrompt("steer", normalized.text, normalized.images, { queueKey: options.queueKey, agentMessageId: options.agentMessageId, @@ -4929,6 +4928,7 @@ export class AgentSession { throw new Error("Queued prompt normalization did not produce a prompt"); } + this._resumeGoalForUserInput(); return this._queuePreparedPrompt("followUp", normalized.text, normalized.images, { queueKey: options.queueKey, agentMessageId: options.agentMessageId, diff --git a/packages/coding-agent/src/core/goals.ts b/packages/coding-agent/src/core/goals.ts index 0c3ea05df..a9f2c8fa8 100644 --- a/packages/coding-agent/src/core/goals.ts +++ b/packages/coding-agent/src/core/goals.ts @@ -13,12 +13,14 @@ export const MAX_THREAD_GOAL_OBJECTIVE_CHARS = 4000; * or new user message before the goal pauses as waiting for user input. * Below this the continuation is a legitimate nudge; at this count the model * has repeatedly declined to act and re-injecting the same context cannot - * make progress. + * make progress. Deliberately the goal-mode analogue of + * `DEFAULT_AUTONOMOUS_LIMITS.maxContinuations`. */ export const MAX_STALLED_GOAL_CONTINUATIONS = 3; export type GoalStatus = "idle" | "active" | "paused" | "budget_limited" | "complete" | "error"; export type GoalContextKind = "continuation" | "budget_limit" | "objective_updated"; +export type GoalPausedBy = "user" | "host"; export interface GoalState { active: boolean; @@ -34,11 +36,11 @@ export interface GoalState { lastReason?: string; lastError?: string; /** - * Set when the host paused the goal because continuations stalled. A goal - * paused this way resumes automatically on the next user prompt, unlike an - * explicit `/goal pause`. + * Why a paused goal is paused. "host" means the stall detector paused it + * waiting for user input, and the next user message resumes it; "user" + * means an explicit `/goal pause`, which only `/goal resume` reactivates. */ - waitingForUser?: boolean; + pausedBy?: GoalPausedBy; } /** Goal payload returned to the kernel-side goal skill. Keys are Python-conventional snake_case. */ @@ -85,7 +87,7 @@ export function normalizeGoalState(goal: GoalState): GoalState { tokensUsed: Math.max(0, Math.trunc(goal.tokensUsed)), timeUsedSeconds: Math.max(0, Math.trunc(goal.timeUsedSeconds)), continuationsUsed: Math.max(0, Math.trunc(goal.continuationsUsed)), - waitingForUser: goal.status === "paused" && goal.waitingForUser === true ? true : undefined, + pausedBy: goal.status === "paused" ? goal.pausedBy : undefined, }; } @@ -199,42 +201,30 @@ export function createGoalContextMessage( /** * Whether injecting another goal continuation into this run would only repeat * the previous one. True when the run's trailing `MAX_STALLED_GOAL_CONTINUATIONS` - * goal-context windows all ended without a tool call or a user message: the - * model saw the same context that many times and chose no observable action, - * so the goal should pause and wait for user input instead of looping. + * goal-context windows all ended without progress: the model saw the same + * context that many times and chose no observable action, so the goal should + * pause and wait for user input instead of looping. A tool call is observable + * work and a user message is new information that can unblock the goal, so + * either one ends the stall; host-injected custom messages are neither. */ export function goalContinuationIsStalled(runMessages: AgentMessage[]): boolean { let stalledWindows = 0; - let windowHasProgress = false; // Walk backward; each goal context closes the window of messages after it. for (let index = runMessages.length - 1; index >= 0; index--) { const message = runMessages[index]; if (message.role === "custom" && message.customType === GOAL_CONTEXT_CUSTOM_TYPE) { - if (windowHasProgress) { - break; - } stalledWindows++; if (stalledWindows >= MAX_STALLED_GOAL_CONTINUATIONS) { return true; } continue; } - windowHasProgress ||= goalWindowProgressMessage(message); - } - return false; -} - -/** - * Whether a message counts as progress inside a goal-context window. Tool - * calls are observable work; a user message is new information that can - * unblock the goal. Host-injected custom messages are neither. - */ -function goalWindowProgressMessage(message: AgentMessage): boolean { - if (message.role === "user") { - return true; - } - if (message.role === "assistant") { - return message.content.some((block) => block.type === "toolCall"); + if (message.role === "user") { + return false; + } + if (message.role === "assistant" && message.content.some((block) => block.type === "toolCall")) { + return false; + } } return false; } diff --git a/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts b/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts index 9f4d3857e..58cff08e2 100644 --- a/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts +++ b/packages/coding-agent/test/suite/regressions/986-goal-continuation-loop.test.ts @@ -3,7 +3,11 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import type { AgentSession } from "../../../src/core/agent-session.js"; -import { goalContinuationIsStalled } from "../../../src/core/goals.js"; +import { + GOAL_CONTEXT_CUSTOM_TYPE, + goalContinuationIsStalled, + MAX_STALLED_GOAL_CONTINUATIONS, +} from "../../../src/core/goals.js"; import { createHarness, getAssistantTexts, type Harness } from "../harness.js"; /** @@ -34,7 +38,10 @@ function createFauxIpythonTool(sessionRef: { current?: AgentSession }): AgentToo const code = (params as { code: string }).code.trim(); let text = ""; if (code.startsWith("goal.")) { - text = JSON.stringify(session.handleGoalHostRequest(code, {})); + const spaceIndex = code.indexOf(" "); + const type = spaceIndex < 0 ? code : code.slice(0, spaceIndex); + const payload = spaceIndex < 0 ? {} : JSON.parse(code.slice(spaceIndex + 1)); + text = JSON.stringify(session.handleGoalHostRequest(type, payload)); } return { content: [{ type: "text", text }], @@ -46,20 +53,24 @@ function createFauxIpythonTool(sessionRef: { current?: AgentSession }): AgentToo function goalContextMessages(harness: Harness) { return harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === "goal_context", + (message) => message.role === "custom" && message.customType === GOAL_CONTEXT_CUSTOM_TYPE, ); } -function goalContextMessage(): AgentMessage { +function fauxGoalContext(): AgentMessage { return { role: "custom", - customType: "goal_context", + customType: GOAL_CONTEXT_CUSTOM_TYPE, content: "continue", display: true, timestamp: 0, }; } +function waitingReplies(count: number, text: string) { + return Array.from({ length: count }, () => fauxAssistantMessage(text)); +} + describe("regression #986: goal continuation loop", () => { const harnesses: Harness[] = []; @@ -80,61 +91,57 @@ describe("regression #986: goal continuation loop", () => { return harness; } + async function createStallPausedHarness(): Promise { + const harness = await createGoalHarness(); + harness.setResponses(waitingReplies(MAX_STALLED_GOAL_CONTINUATIONS, "Still waiting for approval.")); + await harness.session.prompt("/goal ship the release after approval"); + expect(harness.session.goalState).toMatchObject({ status: "paused", pausedBy: "host" }); + return harness; + } + it("pauses the goal as waiting for user input after repeated continuations without tool calls", async () => { const harness = await createGoalHarness(); - harness.setResponses([ - fauxAssistantMessage("Waiting for the sandbox key."), - fauxAssistantMessage("State unchanged: still waiting for the sandbox key."), - fauxAssistantMessage("State unchanged: still waiting for the sandbox key."), - ]); + harness.setResponses( + waitingReplies(MAX_STALLED_GOAL_CONTINUATIONS, "State unchanged: still waiting for the sandbox key."), + ); await harness.session.prompt("/goal run the evidence harness once the sandbox key arrives"); expect(harness.session.goalState).toMatchObject({ active: false, status: "paused", - waitingForUser: true, - continuationsUsed: 2, + pausedBy: "host", + continuationsUsed: MAX_STALLED_GOAL_CONTINUATIONS - 1, }); expect(harness.session.goalState.lastReason).toContain("Waiting for user input"); - // Initial context plus exactly two continuations; the loop must not run on. - expect(goalContextMessages(harness)).toHaveLength(3); + // Initial context plus the allowed continuations; the loop must not run on. + expect(goalContextMessages(harness)).toHaveLength(MAX_STALLED_GOAL_CONTINUATIONS); expect(harness.getPendingResponseCount()).toBe(0); }); it("resets the stall window when a continuation turn makes a tool call", async () => { const harness = await createGoalHarness(); harness.setResponses([ - fauxAssistantMessage("Thinking about the first step."), - fauxAssistantMessage("Still thinking."), + ...waitingReplies(MAX_STALLED_GOAL_CONTINUATIONS - 1, "Still thinking."), fauxAssistantMessage(fauxToolCall("ipython", { code: "print('working')" }), { stopReason: "toolUse" }), fauxAssistantMessage("Ran the script; waiting for review."), - fauxAssistantMessage("State unchanged: waiting for review."), - fauxAssistantMessage("State unchanged: waiting for review."), - fauxAssistantMessage("State unchanged: waiting for review."), + ...waitingReplies(MAX_STALLED_GOAL_CONTINUATIONS, "State unchanged: waiting for review."), ]); await harness.session.prompt("/goal run the script and wait for review"); expect(harness.session.goalState).toMatchObject({ status: "paused", - waitingForUser: true, + pausedBy: "host", }); - // The tool-call turn resets the stall count, so three more toolless - // continuations are needed before the goal pauses again. - expect(goalContextMessages(harness)).toHaveLength(6); + // The tool-call turn resets the stall count, so a full round of toolless + // continuations is needed again before the goal pauses. + expect(goalContextMessages(harness)).toHaveLength(MAX_STALLED_GOAL_CONTINUATIONS * 2); expect(harness.getPendingResponseCount()).toBe(0); }); it("resumes a stall-paused goal on the next user prompt and keeps continuing", async () => { - const harness = await createGoalHarness(); - harness.setResponses([ - fauxAssistantMessage("Waiting for approval."), - fauxAssistantMessage("Still waiting for approval."), - fauxAssistantMessage("Still waiting for approval."), - ]); - await harness.session.prompt("/goal ship the release after approval"); - expect(harness.session.goalState).toMatchObject({ status: "paused", waitingForUser: true }); + const harness = await createStallPausedHarness(); harness.appendResponses([ fauxAssistantMessage("Approval received, shipping."), @@ -151,23 +158,41 @@ describe("regression #986: goal continuation loop", () => { }); // The user prompt reactivated continuations: one more goal context was // injected after the reply before the model completed the goal. - expect(goalContextMessages(harness)).toHaveLength(4); + expect(goalContextMessages(harness)).toHaveLength(MAX_STALLED_GOAL_CONTINUATIONS + 1); const statusHistory = harness.eventsOfType("goal_update").map((event) => event.goal.status); expect(statusHistory.indexOf("paused")).toBeGreaterThanOrEqual(0); expect(statusHistory.lastIndexOf("active")).toBeGreaterThan(statusHistory.indexOf("paused")); expect(harness.getPendingResponseCount()).toBe(0); }); + it("resumes a stall-paused goal for user input delivered via followUp", async () => { + const harness = await createStallPausedHarness(); + + harness.appendResponses([ + fauxAssistantMessage(fauxToolCall("ipython", { code: "goal.complete" }), { stopReason: "toolUse" }), + fauxAssistantMessage("Shipped."), + ]); + + await harness.session.followUp("Approved, go ahead.", undefined, { resumeIfIdle: true }); + await harness.session.waitForSessionInputIdle(); + + expect(harness.session.goalState).toMatchObject({ + active: false, + status: "complete", + lastReason: "Goal achieved", + }); + expect(harness.getPendingResponseCount()).toBe(0); + }); + it("does not resume an explicitly paused goal on a user prompt", async () => { const harness = await createGoalHarness({ initialGoal: { objective: "hold until told otherwise" } }); await harness.session.prompt("/goal pause"); - expect(harness.session.goalState).toMatchObject({ status: "paused" }); - expect(harness.session.goalState.waitingForUser).toBeUndefined(); + expect(harness.session.goalState).toMatchObject({ status: "paused", pausedBy: "user" }); harness.setResponses([fauxAssistantMessage("Hello!")]); await harness.session.prompt("Hi there."); - expect(harness.session.goalState).toMatchObject({ status: "paused" }); + expect(harness.session.goalState).toMatchObject({ status: "paused", pausedBy: "user" }); expect(goalContextMessages(harness)).toHaveLength(0); expect(getAssistantTexts(harness)).toEqual(["Hello!"]); expect(harness.getPendingResponseCount()).toBe(0); @@ -179,21 +204,24 @@ describe("regression #986: goal continuation loop", () => { fauxAssistantMessage(fauxToolCall("ipython", { code: "print('x')" }), { stopReason: "toolUse" }); const userReply: AgentMessage = { role: "user", content: "new information", timestamp: 0 }; - const threeStalled = [goalContextMessage(), text(), goalContextMessage(), text(), goalContextMessage(), text()]; - expect(goalContinuationIsStalled(threeStalled)).toBe(true); + const stalledRun = Array.from({ length: MAX_STALLED_GOAL_CONTINUATIONS }, () => [ + fauxGoalContext(), + text(), + ]).flat(); + expect(goalContinuationIsStalled(stalledRun)).toBe(true); - const twoStalled = threeStalled.slice(2); - expect(goalContinuationIsStalled(twoStalled)).toBe(false); + const oneWindowShort = stalledRun.slice(2); + expect(goalContinuationIsStalled(oneWindowShort)).toBe(false); - const userInLastWindow = [...threeStalled, userReply]; + const userInLastWindow = [...stalledRun, userReply]; expect(goalContinuationIsStalled(userInLastWindow)).toBe(false); const toolCallInMiddleWindow = [ - goalContextMessage(), + fauxGoalContext(), text(), - goalContextMessage(), + fauxGoalContext(), toolCall(), - goalContextMessage(), + fauxGoalContext(), text(), ]; expect(goalContinuationIsStalled(toolCallInMiddleWindow)).toBe(false);