From 921aaaab4874aeff0a1da8078e13ab8363fc1bf1 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Tue, 22 Sep 2026 20:43:50 +0100 Subject: [PATCH] fix(agent-core): bound the agent loop with a maxTurns cap Invariant: agent-loop turns must be bounded so a model stuck in a tool loop cannot run (and bill) forever; hitting the cap is a distinct terminal status, never "completed". Cause: runLoop was a bare `while (true)` with no turn limit, and stdio-host accepted a `maxTurns` query option but only warned that it was unenforced. Only autopilot resumes were capped separately. Fix: add optional maxTurns to AgentLoopConfig/AgentOptions/Agent (agent-core), stop the loop after the last completed turn's tool results are appended but before draining the steering/follow-up queues (so nothing dangles and nothing queued is silently dropped), and emit agent_end with reason: "max_turns" when the model still had work pending. Add a coding-agent maxTurnsPerPrompt setting (default 200), apply it where the Agent is constructed, surface a notice on cap, and stop autopilot auto-resume from treating a capped run as a transient failure. stdio-host now honors maxTurns for the active query only (scoped, restored on finish) and reports a max_turns stop as subtype "error_max_turns" instead of "success". --- packages/agent-core/src/agent-loop.ts | 20 ++ packages/agent-core/src/agent.ts | 6 + packages/agent-core/src/types.ts | 21 +- packages/agent-core/test/agent-loop.test.ts | 181 ++++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 10 +- .../coding-agent/src/core/extensions/types.ts | 2 + packages/coding-agent/src/core/sdk.ts | 1 + .../coding-agent/src/core/settings-manager.ts | 17 ++ packages/coding-agent/src/features/step.ts | 7 + packages/coding-agent/src/step/permissions.ts | 7 + packages/coding-agent/src/step/stdio-host.ts | 27 ++- .../test/settings-manager.test.ts | 27 +++ .../test/step-permissions.test.ts | 17 ++ .../coding-agent/test/step-stdio-host.test.ts | 108 +++++++++++ .../src/in-process/session-handle.ts | 2 +- 15 files changed, 441 insertions(+), 12 deletions(-) diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index 0de9ec85..a72effe6 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -164,6 +164,9 @@ async function runLoop( let currentContext = initialContext; let config = initialConfig; let lastCompletedTurn: PrepareNextTurnContext | undefined; + // Counts assistant turns (LLM calls) started across this entire run, including + // turns triggered by follow-up messages in the outer loop below. + let turnCount = 0; // Check for steering messages at start (user may have typed while waiting) let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; @@ -173,6 +176,8 @@ async function runLoop( // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) { + turnCount++; + if (lastCompletedTurn) { const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn); if (nextTurnSnapshot) { @@ -267,6 +272,21 @@ async function runLoop( return; } + // Undefined/0 means unlimited. Check here - after the turn's tool results + // are appended and turn_end has fired, but before the steering queue is + // drained - so nothing is left dangling and no queued message is silently + // discarded (it stays queued for the next run). Only report "max_turns" as + // the reason when the model still had more to do; if it had already + // stopped on its own this turn, that is a normal completion. + if (config.maxTurns && config.maxTurns > 0 && turnCount >= config.maxTurns) { + await emit({ + type: "agent_end", + messages: newMessages, + ...(hasMoreToolCalls ? { reason: "max_turns" as const } : {}), + }); + return; + } + pendingMessages = (await config.getSteeringMessages?.()) || []; } diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 68365590..d074daf8 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -120,6 +120,8 @@ export interface AgentOptions { transport?: Transport; maxRetryDelayMs?: number; toolExecution?: ToolExecutionMode; + /** Forwarded to {@link AgentLoopConfig.maxTurns}. Undefined or 0 means unlimited. */ + maxTurns?: number; } class PendingMessageQueue { @@ -212,6 +214,8 @@ export class Agent { public maxRetryDelayMs?: number; /** Tool execution strategy for assistant messages that contain multiple tool calls. */ public toolExecution: ToolExecutionMode; + /** Bounds assistant turns (LLM calls) per run. Undefined or 0 means unlimited. */ + public maxTurns?: number; constructor(options: AgentOptions) { // Older compiled consumers may omit options or streamFn even though the current API requires them. @@ -235,6 +239,7 @@ export class Agent { this.transport = runtimeOptions.transport ?? "auto"; this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs; this.toolExecution = runtimeOptions.toolExecution ?? "parallel"; + this.maxTurns = runtimeOptions.maxTurns; } /** @@ -455,6 +460,7 @@ export class Agent { thinkingBudgets: this.thinkingBudgets, maxRetryDelayMs: this.maxRetryDelayMs, toolExecution: this.toolExecution, + maxTurns: this.maxTurns, beforeToolCall: this.beforeToolCall, afterToolCall: this.afterToolCall, shouldStopAfterTurn: shouldStopAfterTurn diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 5db470b4..6786158c 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -161,6 +161,25 @@ export interface AgentLoopConfig extends SimpleStreamOptions { */ toolCallLeakRetries?: number; + /** + * Bounds the number of assistant turns (LLM calls) started within a single + * `agentLoop`/`agentLoopContinue` run (a "run" spans the outer follow-up-message + * loop, not just one prompt/continue call). + * + * When the limit is reached, the loop stops instead of starting another LLM + * call. The last completed turn's tool results are always fully appended to + * the transcript first, so no tool call is ever left dangling, and any + * already-queued steering/follow-up messages are left queued rather than + * drained, so they are not silently dropped. + * + * `agent_end` carries `reason: "max_turns"` only when the model still had + * more tool calls to make on that final turn. If it happened to stop on its + * own right at the limit, that is a normal completion and `reason` is omitted. + * + * Undefined or 0 means unlimited (the library default). + */ + maxTurns?: number; + /** * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. * @@ -441,7 +460,7 @@ export interface AgentContext { export type AgentEvent = // Agent lifecycle | { type: "agent_start" } - | { type: "agent_end"; messages: AgentMessage[] } + | { type: "agent_end"; messages: AgentMessage[]; reason?: "max_turns" } // Turn lifecycle - a turn is one assistant response + any tool calls/results | { type: "turn_start" } | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } diff --git a/packages/agent-core/test/agent-loop.test.ts b/packages/agent-core/test/agent-loop.test.ts index 79957ef1..229c2db6 100644 --- a/packages/agent-core/test/agent-loop.test.ts +++ b/packages/agent-core/test/agent-loop.test.ts @@ -1730,3 +1730,184 @@ describe("tool-call markup leak retry", () => { expect(calls()).toBe(2); }); }); + +describe("maxTurns", () => { + const toolSchema = Type.Object({ value: Type.String() }); + + function createLoopingTool(executed: string[]): AgentTool { + return { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value); + return { content: [{ type: "text", text: `echoed: ${params.value}` }], details: params }; + }, + }; + } + + /** A faux model that always responds with a tool call, never stopping on its own. */ + function alwaysToolCallStream() { + let call = 0; + const streamFn = () => { + const stream = new MockAssistantStream(); + const index = call++; + queueMicrotask(() => { + const message = createAssistantMessage( + [{ type: "toolCall", id: `tool-${index}`, name: "echo", arguments: { value: `call-${index}` } }], + "toolUse", + ); + stream.push({ type: "done", reason: "toolUse", message }); + }); + return stream; + }; + return { streamFn, calls: () => call }; + } + + it("stops after exactly maxTurns assistant messages with reason 'max_turns' and no dangling tool calls", async () => { + const executed: string[] = []; + const tool = createLoopingTool(executed); + const { streamFn, calls } = alwaysToolCallStream(); + + const context: AgentContext = { systemPrompt: "", messages: [], tools: [tool] }; + const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, maxTurns: 3 }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("go")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + const messages = await stream.result(); + + // Exactly 3 assistant messages were produced - the model was never asked for a 4th. + expect(calls()).toBe(3); + const assistantMessages = messages.filter((m) => m.role === "assistant"); + expect(assistantMessages.length).toBe(3); + + // Every tool call has a matching tool result; none is left dangling. + const toolCallIds = assistantMessages.flatMap((m) => + (m as AssistantMessage).content.filter((c) => c.type === "toolCall").map((c) => c.id), + ); + const toolResultIds = messages + .filter((m) => m.role === "toolResult") + .map((m) => (m as { toolCallId: string }).toolCallId); + expect(toolCallIds.sort()).toEqual(toolResultIds.sort()); + expect(toolCallIds.length).toBe(3); + + const agentEnd = events.find((e) => e.type === "agent_end"); + expect(agentEnd).toBeDefined(); + expect((agentEnd as { reason?: string }).reason).toBe("max_turns"); + }); + + it("runs unlimited turns when maxTurns is unset", async () => { + const executed: string[] = []; + const tool = createLoopingTool(executed); + let call = 0; + const streamFn = () => { + const stream = new MockAssistantStream(); + const index = call++; + queueMicrotask(() => { + if (index < 5) { + const message = createAssistantMessage( + [{ type: "toolCall", id: `tool-${index}`, name: "echo", arguments: { value: `call-${index}` } }], + "toolUse", + ); + stream.push({ type: "done", reason: "toolUse", message }); + } else { + const message = createAssistantMessage([{ type: "text", text: "done" }]); + stream.push({ type: "done", reason: "stop", message }); + } + }); + return stream; + }; + + const context: AgentContext = { systemPrompt: "", messages: [], tools: [tool] }; + const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("go")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + const messages = await stream.result(); + + expect(call).toBe(6); + expect(messages.filter((m) => m.role === "assistant").length).toBe(6); + const agentEnd = events.find((e) => e.type === "agent_end"); + expect((agentEnd as { reason?: string }).reason).toBeUndefined(); + }); + + it("does not report 'max_turns' when the model stops on its own on the capped turn", async () => { + const executed: string[] = []; + const tool = createLoopingTool(executed); + let call = 0; + const streamFn = () => { + const stream = new MockAssistantStream(); + const index = call++; + queueMicrotask(() => { + if (index < 2) { + const message = createAssistantMessage( + [{ type: "toolCall", id: `tool-${index}`, name: "echo", arguments: { value: `call-${index}` } }], + "toolUse", + ); + stream.push({ type: "done", reason: "toolUse", message }); + } else { + // Third turn stops naturally, exactly on the maxTurns boundary. + const message = createAssistantMessage([{ type: "text", text: "done" }]); + stream.push({ type: "done", reason: "stop", message }); + } + }); + return stream; + }; + + const context: AgentContext = { systemPrompt: "", messages: [], tools: [tool] }; + const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, maxTurns: 3 }; + + const events: AgentEvent[] = []; + const stream = agentLoop([createUserMessage("go")], context, config, undefined, streamFn); + for await (const event of stream) { + events.push(event); + } + const messages = await stream.result(); + + expect(call).toBe(3); + expect(messages.filter((m) => m.role === "assistant").length).toBe(3); + const agentEnd = events.find((e) => e.type === "agent_end"); + expect((agentEnd as { reason?: string }).reason).toBeUndefined(); + }); + + it("does not drain queued steering messages once the turn cap is reached", async () => { + const executed: string[] = []; + const tool = createLoopingTool(executed); + const { streamFn } = alwaysToolCallStream(); + + let steeringCalls = 0; + const context: AgentContext = { systemPrompt: "", messages: [], tools: [tool] }; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + maxTurns: 3, + // Always empty: this test cares about *when* the queue is polled, not + // about delivering a message. + getSteeringMessages: async () => { + steeringCalls++; + return []; + }, + }; + + const stream = agentLoop([createUserMessage("go")], context, config, undefined, streamFn); + for await (const _event of stream) { + // drain + } + const messages = await stream.result(); + + expect(messages.filter((m) => m.role === "user").length).toBe(1); + // One poll before the run starts, plus one at the start and one at the end + // of each of the 3 permitted turns, minus the trailing poll after the + // last turn (turn 3) - the loop returns for the max_turns cap before + // reaching it. A message queued at that point must stay queued for the + // next run instead of being silently dropped, so there must be no 6th call. + expect(steeringCalls).toBe(5); + }); +}); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 945f3200..573f13e5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -150,6 +150,8 @@ export type AgentSessionEvent = type: "agent_end"; messages: AgentMessage[]; willRetry: boolean; + /** Set when the loop stopped because it hit its configured turn cap rather than finishing normally. */ + reason?: "max_turns"; } | { type: "agent_settled" } | { @@ -843,7 +845,7 @@ export class AgentSession { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); + await this._extensionRunner.emit({ type: "agent_end", messages: event.messages, reason: event.reason }); } else if (event.type === "turn_start") { const extensionEvent: TurnStartEvent = { type: "turn_start", @@ -1258,8 +1260,10 @@ export class AgentSession { return true; } - // The agent loop drains both queues before emitting agent_end. Any messages - // here were queued by agent_end extension handlers and need a continuation. + // The agent loop drains both queues before emitting agent_end, unless it + // stopped because it hit maxTurns - that path deliberately leaves an + // already-queued message queued instead of draining it. Either way, any + // messages left here need a continuation (a fresh maxTurns budget). return this.agent.hasQueuedMessages(); } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ea9c6946..7a83215d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -781,6 +781,8 @@ export interface AgentStartEvent { export interface AgentEndEvent { type: "agent_end"; messages: AgentMessage[]; + /** Set when the loop stopped because it hit its configured turn cap rather than finishing normally. */ + reason?: "max_turns"; } /** Fired after an agent run has fully settled and no automatic retry, compaction, or queued continuation will run. */ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f07d5a34..81012c7e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -419,6 +419,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, + maxTurns: settingsManager.getMaxTurnsPerPrompt(), }); // Restore messages if session has existing data diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 161796c7..8b62e468 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -11,6 +11,9 @@ import { stripBom } from "../utils/text.ts"; import type { ContextProjectionMode } from "./compaction/projection.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; +/** Default cap on assistant turns (LLM calls) per prompt/continue run. 0 disables the cap. */ +export const DEFAULT_MAX_TURNS_PER_PROMPT = 200; + export interface CompactionSettings { enabled?: boolean; // default: true reserveTokens?: number; // default: 16384 @@ -145,6 +148,7 @@ export interface Settings { fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode + maxTurnsPerPrompt?: number; // Max assistant turns (LLM calls) per prompt/continue run; default 200, 0 disables } function isMergeableObject(value: unknown): value is Record { @@ -926,6 +930,19 @@ export class SettingsManager { this.save(); } + getMaxTurnsPerPrompt(): number { + return this.settings.maxTurnsPerPrompt ?? DEFAULT_MAX_TURNS_PER_PROMPT; + } + + setMaxTurnsPerPrompt(maxTurns: number): void { + if (!Number.isFinite(maxTurns) || maxTurns < 0) { + throw new Error(`Invalid maxTurnsPerPrompt setting: ${String(maxTurns)}`); + } + this.globalSettings.maxTurnsPerPrompt = Math.floor(maxTurns); + this.markModified("maxTurnsPerPrompt"); + this.save(); + } + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index 2c51567d..661edc54 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -251,6 +251,13 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi }); pi.on("agent_end", (event) => { + if (event.reason === "max_turns") { + try { + notify?.("Stopped: reached the maxTurnsPerPrompt limit for this prompt.", "warning"); + } catch { + // The UI may have been torn down while this event was in flight. + } + } autoResume.handleAgentEnd(event); }); pi.on("agent_settled", (_event, ctx) => { diff --git a/packages/coding-agent/src/step/permissions.ts b/packages/coding-agent/src/step/permissions.ts index 31b945c9..dbe51621 100644 --- a/packages/coding-agent/src/step/permissions.ts +++ b/packages/coding-agent/src/step/permissions.ts @@ -686,6 +686,13 @@ export class StepAutoResumeController { } handleAgentEnd(event: AgentEndEvent): void { + // Hitting the configured turn cap is a bounded-loop safeguard, not a + // transient model/transport failure - resuming would just run the same + // stuck loop for another maxTurns turns. Treat it like a clean stop. + if (event.reason === "max_turns") { + this.reset(); + return; + } const failure = describeAssistantFailure(event.messages); if (!failure) { this.reset(); diff --git a/packages/coding-agent/src/step/stdio-host.ts b/packages/coding-agent/src/step/stdio-host.ts index bd6e905a..8cbc95a5 100644 --- a/packages/coding-agent/src/step/stdio-host.ts +++ b/packages/coding-agent/src/step/stdio-host.ts @@ -110,6 +110,8 @@ interface ActiveQuery { interrupted: boolean; errorMessage?: string; finished: boolean; + /** Set when an `agent_end` for this query reported `reason: "max_turns"`. */ + maxTurnsReached?: boolean; } interface PendingRequest { @@ -659,20 +661,26 @@ export class StepStdioHost { if (query.finished) return; query.finished = true; const text = this.#session.getLastAssistantText() ?? ""; + // Hitting the turn cap is a distinct terminal state: it must never report + // as "success", even though nothing errored and the query wasn't interrupted. + const maxTurnsReached = query.maxTurnsReached === true; + const effectiveIsError = isError || maxTurnsReached; this.#emitMessage(query, { type: "result", - subtype: isError ? "error_during_execution" : "success", + subtype: maxTurnsReached ? "error_max_turns" : effectiveIsError ? "error_during_execution" : "success", session_id: query.sessionId, duration_ms: Date.now() - query.startedAt, duration_api_ms: Date.now() - query.startedAt, - is_error: isError, + is_error: effectiveIsError, num_turns: query.numTurns, - ...(isError + ...(effectiveIsError ? { errors: [ cause instanceof Error ? cause.message - : (query.errorMessage ?? (query.interrupted ? "Interrupted" : "Agent execution failed")), + : maxTurnsReached + ? "Reached the maxTurns limit for this query." + : (query.errorMessage ?? (query.interrupted ? "Interrupted" : "Agent execution failed")), ], } : { result: text }), @@ -692,6 +700,10 @@ export class StepStdioHost { const previousTools = agent.state.tools; const previousBeforeToolCall = agent.beforeToolCall; const previousAfterToolCall = agent.afterToolCall; + const previousMaxTurns = agent.maxTurns; + if (typeof query.options.maxTurns === "number") { + agent.maxTurns = query.options.maxTurns; + } const sdkTools = (query.options.sdkTools ?? []).map((descriptor) => this.#createSdkTool(query, descriptor)); const existingNames = new Set(previousTools.map((tool) => tool.name)); const acceptedTools = sdkTools.filter((tool) => { @@ -753,6 +765,7 @@ export class StepStdioHost { agent.state.tools = previousTools; agent.beforeToolCall = previousBeforeToolCall; agent.afterToolCall = previousAfterToolCall; + agent.maxTurns = previousMaxTurns; }; } @@ -1079,6 +1092,8 @@ export class StepStdioHost { ], }, }); + } else if (event.type === "agent_end") { + if (event.reason === "max_turns") query.maxTurnsReached = true; } else if (event.type === "agent_settled") { this.#maybeFinish(query); } @@ -1389,6 +1404,7 @@ function normalizeQueryOptions(value: unknown): StepQueryOptions { : {}), ...(typeof options.model === "string" ? { model: options.model } : {}), ...(typeof options.maxThinkingTokens === "number" ? { maxThinkingTokens: options.maxThinkingTokens } : {}), + ...(typeof options.maxTurns === "number" ? { maxTurns: options.maxTurns } : {}), ...(Array.isArray(options.sdkTools) ? { sdkTools: options.sdkTools.filter(isSdkToolDescriptor), @@ -1432,9 +1448,6 @@ function collectOptionWarnings(options: StepQueryOptions): string[] { if (options.appendSystemPrompt !== undefined) { warnings.push("appendSystemPrompt is not supported for an already-created Step session."); } - if (options.maxTurns !== undefined) { - warnings.push("maxTurns is not enforced by this adapter; Step's configured agent loop limit remains active."); - } if (options.permissionMode === "bypassPermissions") { warnings.push( "permissionMode=bypassPermissions skips SDK approval callbacks; use it only in an isolated environment.", diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 68524325..9d28349f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -377,6 +377,33 @@ describe("SettingsManager", () => { }); }); + describe("maxTurnsPerPrompt", () => { + it("should default to 200", () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getMaxTurnsPerPrompt()).toBe(200); + }); + + it("should use merged global and project settings", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ maxTurnsPerPrompt: 50 })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ maxTurnsPerPrompt: 0 })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getMaxTurnsPerPrompt()).toBe(0); + }); + + it("should persist a new value via the setter", () => { + const manager = SettingsManager.create(projectDir, agentDir); + manager.setMaxTurnsPerPrompt(10); + expect(manager.getMaxTurnsPerPrompt()).toBe(10); + }); + + it("should reject invalid values", () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(() => manager.setMaxTurnsPerPrompt(-1)).toThrow("Invalid maxTurnsPerPrompt setting"); + }); + }); + describe("externalEditor", () => { const originalVisual = process.env.VISUAL; const originalEditor = process.env.EDITOR; diff --git a/packages/coding-agent/test/step-permissions.test.ts b/packages/coding-agent/test/step-permissions.test.ts index 53943a97..cd3b8d5a 100644 --- a/packages/coding-agent/test/step-permissions.test.ts +++ b/packages/coding-agent/test/step-permissions.test.ts @@ -411,6 +411,23 @@ describe("Step autopilot continuation", () => { }); }); + it("does not treat a max_turns stop as a resumable failure, even if the last assistant message errored", () => { + const setTimer = vi.fn(() => 1 as unknown as ReturnType); + const controller = new StepAutoResumeController({ + isEnabled: () => true, + canResume: () => true, + resume: vi.fn(), + setTimer, + }); + controller.handleAgentEnd({ + type: "agent_end", + reason: "max_turns", + messages: [{ role: "assistant", stopReason: "error", errorMessage: "network down" }], + } as never); + controller.handleAgentSettled(); + expect(setTimer).not.toHaveBeenCalled(); + }); + it("does not schedule when disabled or when the run has pending work", () => { const setTimer = vi.fn(() => 1 as unknown as ReturnType); const controller = new StepAutoResumeController({ diff --git a/packages/coding-agent/test/step-stdio-host.test.ts b/packages/coding-agent/test/step-stdio-host.test.ts index 12c9b3d4..fc869015 100644 --- a/packages/coding-agent/test/step-stdio-host.test.ts +++ b/packages/coding-agent/test/step-stdio-host.test.ts @@ -258,6 +258,114 @@ describe("StepStdioHost", () => { await running; }); + test("honors query.start options.maxTurns by setting it on the Pi agent, without an unenforced warning", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const decoder = new StepStdioFrameDecoder(); + const frames: ReturnType = []; + output.on("data", (chunk: Buffer) => frames.push(...decoder.push(chunk))); + const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 1_000; + while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)); + expect(predicate()).toBe(true); + }; + const { runtime, session } = createFakeRuntime(true); + const host = new StepStdioHost({ runtimeHost: runtime, input, output }); + const running = host.run(); + const envelope = (id: string, method: string, payload: unknown): Buffer => + encodeStepStdioFrame({ + protocol: "step-agent-sdk", + version: 1, + kind: "request", + id, + method, + payload, + }); + + input.write(envelope("init", "initialize", { protocolRange: { min: 1, max: 1 } })); + await waitFor(() => frames.some((frame) => frame.kind === "response" && frame.replyTo === "init")); + input.write( + envelope("query", "query.start", { + streamingInput: true, + options: { maxTurns: 5 }, + }), + ); + await waitFor(() => frames.some((frame) => frame.kind === "response" && frame.replyTo === "query")); + + const agent = (session as unknown as { agent: { maxTurns?: number } }).agent; + expect(agent.maxTurns).toBe(5); + + const initEvent = frames.find( + (frame) => + frame.kind === "event" && (frame.payload as { message?: { subtype?: string } }).message?.subtype === "init", + ); + const warnings = (initEvent?.payload as { message?: { warnings?: string[] } }).message?.warnings ?? []; + expect(warnings.some((warning) => warning.includes("maxTurns"))).toBe(false); + + // The override is scoped to this query, not persisted on the session. + input.write(envelope("input-end", "query.input_end", {})); + await waitFor(() => + frames.some( + (frame) => + frame.kind === "event" && (frame.payload as { message?: { type?: string } }).message?.type === "result", + ), + ); + expect(agent.maxTurns).toBeUndefined(); + + await host.close(); + await running; + }); + + test("reports a max_turns agent_end as a distinct, non-success result", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const decoder = new StepStdioFrameDecoder(); + const frames: ReturnType = []; + output.on("data", (chunk: Buffer) => frames.push(...decoder.push(chunk))); + const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 1_000; + while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)); + expect(predicate()).toBe(true); + }; + const { runtime, emit } = createFakeRuntime(); + const host = new StepStdioHost({ runtimeHost: runtime, input, output }); + const running = host.run(); + const envelope = (id: string, method: string, payload: unknown): Buffer => + encodeStepStdioFrame({ + protocol: "step-agent-sdk", + version: 1, + kind: "request", + id, + method, + payload, + }); + + input.write(envelope("init", "initialize", { protocolRange: { min: 1, max: 1 } })); + await waitFor(() => frames.some((frame) => frame.kind === "response" && frame.replyTo === "init")); + input.write(envelope("query", "query.start", { streamingInput: true })); + await waitFor(() => frames.some((frame) => frame.kind === "response" && frame.replyTo === "query")); + + // Simulate the agent loop hitting its turn cap: never treated as "completed". + emit({ type: "agent_end", messages: [], willRetry: false, reason: "max_turns" } as unknown as AgentSessionEvent); + input.write(envelope("input-end", "query.input_end", {})); + await waitFor(() => + frames.some( + (frame) => + frame.kind === "event" && (frame.payload as { message?: { type?: string } }).message?.type === "result", + ), + ); + const result = frames.find( + (frame) => + frame.kind === "event" && (frame.payload as { message?: { type?: string } }).message?.type === "result", + ); + expect(result?.payload).toMatchObject({ + message: { type: "result", subtype: "error_max_turns", is_error: true }, + }); + + await host.close(); + await running; + }); + test("bridges SDK tools through the Pi agent and preserves the result", async () => { const input = new PassThrough(); const output = new PassThrough(); diff --git a/packages/contracts/src/in-process/session-handle.ts b/packages/contracts/src/in-process/session-handle.ts index 75e4900a..7c20d231 100644 --- a/packages/contracts/src/in-process/session-handle.ts +++ b/packages/contracts/src/in-process/session-handle.ts @@ -40,7 +40,7 @@ export interface AgentMessagePreview { */ export type AgentEvent = | { readonly type: "agent_start" } - | { readonly type: "agent_end"; readonly willRetry: boolean } + | { readonly type: "agent_end"; readonly willRetry: boolean; readonly reason?: "max_turns" } | { readonly type: "agent_settled" } | { readonly type: "turn_start" } | { readonly type: "turn_end"; readonly message: AgentMessagePreview }