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
20 changes: 20 additions & 0 deletions packages/agent-core/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()) || [];

Expand All @@ -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) {
Expand Down Expand Up @@ -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?.()) || [];
}

Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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[] }
Expand Down
181 changes: 181 additions & 0 deletions packages/agent-core/test/agent-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof toolSchema, { value: string }> {
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);
});
});
10 changes: 7 additions & 3 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
| {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
}

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> {
Expand Down Expand Up @@ -926,6 +930,19 @@ export class SettingsManager {
this.save();
}

getMaxTurnsPerPrompt(): number {
return this.settings.maxTurnsPerPrompt ?? DEFAULT_MAX_TURNS_PER_PROMPT;
}
Comment on lines +933 to +935

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,
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/features/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading