summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src/runtime
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 19:03:09 +0900
committerAdam Malczewski <[email protected]>2026-06-26 19:03:09 +0900
commite8b4bf1fe4fedc48bd0dc56b5745467542946474 (patch)
tree1d7c31f23bc7fccdf3052dc59ff8da63700eade8 /packages/kernel/src/runtime
parent9b91d1bca83e7599fb7d7de6038cedf186e61764 (diff)
downloaddispatch-e8b4bf1fe4fedc48bd0dc56b5745467542946474.tar.gz
dispatch-e8b4bf1fe4fedc48bd0dc56b5745467542946474.zip
fix(kernel): disable MAX_STEPS limit (0 = unlimited)
Agents were being cut off mid-task at 50 steps. The MAX_STEPS=50 hardcoded limit was silently terminating turns while the model was actively making tool calls, leaving conversations idle with a dangling tool-result as the last chunk. Setting MAX_STEPS to 0 disables the limit — the loop runs until the model stops making tool calls naturally or the abort signal fires. The max-steps code path is preserved for when MAX_STEPS > 0.
Diffstat (limited to 'packages/kernel/src/runtime')
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts36
-rw-r--r--packages/kernel/src/runtime/run-turn.ts8
2 files changed, 29 insertions, 15 deletions
diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts
index a9fc3d9..59e7fab 100644
--- a/packages/kernel/src/runtime/run-turn.test.ts
+++ b/packages/kernel/src/runtime/run-turn.test.ts
@@ -5,7 +5,7 @@ import type { LogDeps, Logger, LogRecord, LogSink } from "../contracts/logging.j
import type { ProviderContract, ProviderEvent } from "../contracts/provider.js";
import type { ToolContract, ToolExecuteContext, ToolResult } from "../contracts/tool.js";
import { createLogger } from "../logging/logger.js";
-import { MAX_STEPS, runTurn } from "./run-turn.js";
+import { runTurn } from "./run-turn.js";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -2853,12 +2853,22 @@ describe("runTurn", () => {
expect(drainCallCount).toBe(2);
});
- it("drainSteering NOT called when max-steps ends the turn after a tool-call step (no next step → no drain)", async () => {
+ it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => {
let drainCallCount = 0;
- // Every step produces a tool call → the turn runs to MAX_STEPS.
- const script: ProviderEvent[][] = Array.from({ length: MAX_STEPS }, () => [
- { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} },
- { type: "finish", reason: "tool-calls" },
+ // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step
+ // to end the turn naturally.
+ const STEPS_WITH_TOOLS = 100;
+ const script: ProviderEvent[][] = [];
+ for (let i = 0; i < STEPS_WITH_TOOLS; i++) {
+ script.push([
+ { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ]);
+ }
+ // Final step: text only, no tool calls → natural end.
+ script.push([
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
]);
const provider = createFakeProvider(script);
@@ -2878,12 +2888,14 @@ describe("runTurn", () => {
},
});
- expect(result.finishReason).toBe("max-steps");
- // MAX_STEPS tool-call steps (indices 0..MAX_STEPS-1). Drained on every
- // step that is followed by a next step (0..MAX_STEPS-2 = MAX_STEPS-1
- // calls); the final step is the max-steps boundary → no next step →
- // no drain (queue left intact for the caller).
- expect(drainCallCount).toBe(MAX_STEPS - 1);
+ // Turn ended naturally, NOT via max-steps.
+ expect(result.finishReason).toBe("stop");
+ // Every tool-call step (0..99) is followed by a next step → each
+ // triggers a drain. The text-only step breaks before draining.
+ expect(drainCallCount).toBe(STEPS_WITH_TOOLS);
+ // All 101 steps produced messages (100 tool steps with assistant +
+ // tool messages, 1 text-only step with an assistant message).
+ expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1);
});
});
diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts
index ac87a1f..273482f 100644
--- a/packages/kernel/src/runtime/run-turn.ts
+++ b/packages/kernel/src/runtime/run-turn.ts
@@ -27,7 +27,9 @@ import {
usageEvent,
} from "./events.js";
-export const MAX_STEPS = 50;
+/** Max steps per turn. 0 = unlimited (the loop runs until the model stops
+ * making tool calls or the abort signal fires). */
+export const MAX_STEPS = 0;
function zeroUsage(): Usage {
return { inputTokens: 0, outputTokens: 0 };
@@ -615,7 +617,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
input.emit(turnStartEvent(conversationId, turnId));
try {
- for (let step = 0; step < MAX_STEPS; step++) {
+ for (let step = 0; MAX_STEPS === 0 || step < MAX_STEPS; step++) {
if (signal.aborted) {
finishReason = "aborted";
break;
@@ -708,7 +710,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
break;
}
- if (step === MAX_STEPS - 1) {
+ if (MAX_STEPS > 0 && step === MAX_STEPS - 1) {
finishReason = "max-steps";
// No next step → no tool-result boundary. Leave any pending
// steering messages for the caller (it owns the queue).