Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/long-running-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 35 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,10 @@ import {
GOAL_SKILL_NAME,
GOAL_STATE_CUSTOM_TYPE,
type GoalHostResponse,
type GoalPausedBy,
type GoalState,
type GoalStatus,
goalContinuationIsStalled,
goalHostResponse,
goalTokenDeltaForUsage,
isPersistedGoalState,
Expand Down Expand Up @@ -1804,7 +1806,7 @@ export class AgentSession {
this._setGoalState(emptyGoalState());
}

private _pauseGoal(reason = "Paused by user"): void {
private _pauseGoal(reason = "Paused by user", pausedBy: GoalPausedBy = "user"): void {
this._clearQueuedGoalContexts();
if (this._goalState.status !== "active") {
this._emitGoalUpdate();
Expand All @@ -1815,11 +1817,30 @@ export class AgentSession {
...goal,
active: false,
status: "paused",
pausedBy,
lastReason: reason,
lastError: undefined,
});
}

/**
* 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.pausedBy !== "host") {
return;
}
this._setGoalState({
...this._goalState,
active: true,
status: "active",
lastReason: undefined,
});
}

private async _resumeGoal(): Promise<void> {
if (!this._goalState.objective) {
this._emitGoalUpdate();
Expand Down Expand Up @@ -3171,6 +3192,10 @@ export class AgentSession {
return [];
}
try {
if (goalContinuationIsStalled(context.newMessages)) {
this._pauseGoal("Waiting for user input: goal continuations repeatedly ended without tool calls", "host");
return [];
}
this._ensureGoalRuntimeActive(context.context);
const nextGoal = {
...this._goalState,
Expand Down Expand Up @@ -4665,6 +4690,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;
Expand Down Expand Up @@ -4862,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,
Expand Down Expand Up @@ -4895,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,
Expand Down
54 changes: 53 additions & 1 deletion packages/coding-agent/src/core/goals.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -7,8 +8,19 @@ 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. 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;
Expand All @@ -23,6 +35,12 @@ export interface GoalState {
updatedAt?: number;
lastReason?: string;
lastError?: string;
/**
* 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.
*/
pausedBy?: GoalPausedBy;
}

/** Goal payload returned to the kernel-side goal skill. Keys are Python-conventional snake_case. */
Expand Down Expand Up @@ -69,6 +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)),
pausedBy: goal.status === "paused" ? goal.pausedBy : undefined,
};
}

Expand Down Expand Up @@ -179,6 +198,37 @@ 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 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;
// 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) {
stalledWindows++;
if (stalledWindows >= MAX_STALLED_GOAL_CONTINUATIONS) {
return true;
}
continue;
}
if (message.role === "user") {
return false;
}
if (message.role === "assistant" && message.content.some((block) => block.type === "toolCall")) {
return false;
}
}
return false;
}

export function formatGoalUsage(goal: GoalState): string | undefined {
if (goal.tokenBudget !== undefined) {
return `${goal.tokensUsed} / ${goal.tokenBudget} tokens`;
Expand Down Expand Up @@ -226,7 +276,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 {
Expand Down
Loading