feat(core,server,docs): default max_turns to -1 (unlimited) to avoid unexpected run cutoffs (#232)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-07 18:53:03 +08:00
committed by GitHub
parent 4a899a265f
commit 6e1c7cc28e
12 changed files with 80 additions and 22 deletions
+2 -2
View File
@@ -165,7 +165,7 @@ export interface ContextEngineDeps {
trace?: TraceSink;
/** Engine initial state (derived by replaying Trace on Session resumption). */
initialState?: EngineInitialState;
/** Maximum LLM turns for a single Task. Defaults to 100; -1 removes the cap. */
/** Maximum LLM turns for a single Task; -1 removes the cap. Omitted means -1 too — the agent-config default and the SDK fallback agree (unlimited). */
maxTurns?: number;
/**
* Maximum automatic retries for LLM timeout/reconnect within a single run. Defaults
@@ -410,7 +410,7 @@ export class ContextEngine {
private taskRunning = false;
constructor(private readonly deps: ContextEngineDeps) {
this.maxTurns = deps.maxTurns ?? 100;
this.maxTurns = deps.maxTurns ?? -1;
this.maxReconnects = deps.maxReconnects ?? 5;
this.reconnectBackoffMs = deps.reconnectBackoffMs ?? 250;
this.reconnectBackoffMaxMs = deps.reconnectBackoffMaxMs ?? 30_000;
+4 -4
View File
@@ -20,7 +20,7 @@
* the file, so re-firing would loop the same cutoff forever, and
* - a hard round cap (`maxRounds`, default 100) as a runaway backstop independent of the
* budget — without it an unbudgeted goal whose model simply never writes the file would
* loop without bound.
* loop without bound; an explicit -1 disables the cap.
* All of these stop the loop without re-firing. The loop writes GOAL.yaml exactly once,
* at creation; afterwards it only READS `status` — every ending leaves the model's own
* last write on disk (system endings exist only as the `goal_finished` outcome), which is
@@ -57,7 +57,7 @@ export interface GoalLoopOptions {
* Hard cap on regular rounds, a runaway backstop independent of the budget (the budget
* wrap-up may run one round past it, so the true bound is maxRounds + 1 — the wrap-up
* fires once and cannot loop). Default 100 — far above any legitimate goal (each round is
* a full Task), so hosts don't expose it as a knob.
* a full Task), so hosts don't expose it as a knob; an explicit -1 disables the cap.
*/
maxRounds?: number;
signal?: AbortSignal;
@@ -141,8 +141,8 @@ export async function* runGoalLoop(
yield finish("aborted");
return;
}
// Runaway backstop, independent of the budget (which may be unlimited).
if (rounds >= maxRounds) {
// Runaway backstop, independent of the budget (which may be unlimited); -1 disables.
if (maxRounds > 0 && rounds >= maxRounds) {
yield finish("aborted");
return;
}
+2 -2
View File
@@ -40,7 +40,7 @@ export interface SessionConfig {
llm: LLMInterface;
environment: EnvironmentInterface;
trace?: TraceSink;
/** Maximum LLM turns per Task (default 100; -1 removes the cap). */
/** Maximum LLM turns per Task; -1 removes the cap. Omitted means -1 too — the agent-config default and the SDK fallback agree (unlimited). */
maxTurns?: number;
/** Creates a new LLM object after compaction (carries over the Session's accumulated Token count); context compaction is unavailable if not provided. */
createLLM?: (sessionTokens: TokenCounts) => LLMInterface;
@@ -83,7 +83,7 @@ export interface SessionConfig {
export interface GoalRunOptions {
/** Token budget; omitted or -1 (`UNLIMITED_BUDGET`) means no budget. */
budget?: number;
/** Hard cap on rounds — a runaway backstop, not a host knob (default 100; see goal-loop.ts). */
/** Hard cap on rounds — a runaway backstop, not a host knob (default 100; -1 disables; see goal-loop.ts). */
maxRounds?: number;
}
+8 -2
View File
@@ -65,7 +65,11 @@ export interface SystemConfig {
version?: number;
/** System-level Prompt (relatively stable; should not be modified frequently). */
system_prompt: string;
/** Max LLM turns per Task (a runtime parameter that belongs to Agent config, not specified when creating a Session). */
/**
* Max LLM turns per Task (a runtime parameter that belongs to Agent config, not specified
* when creating a Session). A positive integer caps the Task; -1 (the default) removes the
* cap so long runs are never cut off mid-task. Valid values are > 0 or exactly -1.
*/
max_turns?: number;
model?: {
max_tokens?: number;
@@ -492,7 +496,9 @@ export function defaultSystemConfig(): SystemConfig {
return {
version: 1,
system_prompt: DEFAULT_SYSTEM_PROMPT,
max_turns: 100,
// -1 = unlimited (same sentinel as compaction.max_session_turns): an agent run is never
// cut off by a turn cap unless the user configures a positive limit themselves.
max_turns: -1,
model: {
max_tokens: 32000,
thinking_level: "medium",
+5 -3
View File
@@ -479,7 +479,7 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
expect(deniedMsg).toBeDefined();
});
it("max_turns default is 100", () => {
it("engine maxTurns fallback is -1 (unlimited) when the option is omitted (direct SDK construction)", () => {
const engine = new ContextEngine({
llm: new FakeLLM(),
environment: new Environment({
@@ -487,8 +487,10 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
toolConfig: execCommandToolConfig(),
}),
});
// Reads the default via a private field (white-box, only testing the default).
expect((engine as unknown as { maxTurns: number }).maxTurns).toBe(100);
// Reads the fallback via a private field (white-box, only testing the fallback). The
// SDK-construction fallback for an omitted option now matches the agent-config default
// (defaultSystemConfig().max_turns, asserted in state.test.ts): -1 = no turn cap.
expect((engine as unknown as { maxTurns: number }).maxTurns).toBe(-1);
});
it("streams the max-turns stop note before the complete text (no extra leading newline)", async () => {
+14
View File
@@ -297,6 +297,20 @@ describe("runGoalLoop", () => {
expect(await readGoalStatus(file)).toBe("active");
});
it("an explicit maxRounds of -1 disables the backstop: the goal runs past 100 rounds", async () => {
// The default backstop stays 100; -1 is the explicit opt-out. Round 103 claims
// complete, which the default cap would have ended as `aborted` at round 100.
const behaviors = Array.from({ length: 103 }, (_, i) =>
i === 102 ? { then: () => setStatus("complete") } : {},
);
const session = fakeSession(behaviors);
const { outcome } = await drain(
runGoalLoop(session, { text: "o", goalFilePath: file, maxRounds: -1 }),
);
expect(outcome).toEqual({ outcome: "complete", rounds: 103, tokensUsed: 0 });
expect(session.prompts).toHaveLength(103);
});
it("an abort landing between rounds stops the loop without a phantom round", async () => {
const ac = new AbortController();
// The signal aborts AFTER round 1's stream ends — no abort event ever hits the stream,
+4
View File
@@ -130,6 +130,10 @@ describe("loadOrInitAgentState", () => {
expect(state.systemConfig.system_prompt).toContain("Caller agent");
// The default AGENTS.md is empty: it carries no preset guidance.
expect(state.agentsMd).toBe("");
// The default turn cap is the -1 sentinel: a new agent has no per-Task turn limit, so
// long runs are never cut off unless the user configures a positive cap. Existing
// agents keep their stored max_turns verbatim (config is never auto-merged).
expect(state.systemConfig.max_turns).toBe(-1);
expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
+1 -1
View File
@@ -13,7 +13,7 @@ This page shows the context_engine's overall flow first, then breaks down each s
session.run(newMessages, { approve, signal })
│ carry-over from a previous interrupt? → prepend to this run's input
▼
┌── turn loop (≤ max_turns, default 100) ───────────────────────┐
┌── turn loop (≤ max_turns; default -1 = no cap) ───────────────┐
│ │
│ request_begin │
│ LLM.streamGenerate(newMessages) │
+1 -1
View File
@@ -13,7 +13,7 @@ SDK 的唯一执行入口是 `session.run(newMessages, opts?)`:输入本次新
session.run(newMessages, { approve, signal })
│ 存在上次中断的补发内容?→ 前置到本轮输入
▼
┌── 轮循环(≤ max_turns,默认 100)──────────────────────────────┐
┌── 轮循环(≤ max_turns,默认 -1 不限制)────────────────────────┐
│ │
│ request_begin │
│ LLM.streamGenerate(newMessages) │
+3 -2
View File
@@ -98,7 +98,7 @@ Edit this file via the CLI (`penguin config model …`) or the Web Models page
| `description` | — | Agent description |
| `version` | `1` | Agent State version (a natural number), incremented on each successful optimization |
| `system_prompt` | built-in template | Required; the only template with placeholder substitution |
| `max_turns` | `100` | Maximum LLM turns per Task (-1 removes the cap) |
| `max_turns` | `-1` | Maximum LLM turns per Task (`-1` = unlimited; a positive integer caps the Task) |
| `model.max_tokens` | `32000` | Output Token limit per Request (-1 = no cap, provider default) |
| `model.thinking_level` | `medium` | `none` / `low` / `medium` / `high` / `xhigh`; the session default, overridable per-Task |
| `model.timeoutMs` | `120000` | Per-Request timeout (milliseconds) |
@@ -122,7 +122,8 @@ version: 3
system_prompt: |
…
max_turns: 100
# -1 (the default) = unlimited; set a positive integer to cap the turns of a single Task.
max_turns: -1
model:
max_tokens: 32000
+3 -2
View File
@@ -98,7 +98,7 @@ output = 0.857143
| `description` | — | Agent 描述 |
| `version` | `1` | Agent State 版本号(自然数),每次成功优化自增 |
| `system_prompt` | 内置模板 | 必填;唯一进行占位符替换的模板 |
| `max_turns` | `100` | 单个 Task 的最大 LLM 轮数(-1 不限制) |
| `max_turns` | `-1` | 单个 Task 的最大 LLM 轮数(`-1` 不限制,正整数为上限) |
| `model.max_tokens` | `32000` | 单次输出 Token 上限(-1 不设上限,用服务商默认) |
| `model.thinking_level` | `medium` | `none` / `low` / `medium` / `high` / `xhigh`;作为会话默认档位,可被逐轮 Task 参数覆盖 |
| `model.timeoutMs` | `120000` | 单次 Request 超时(毫秒) |
@@ -122,7 +122,8 @@ version: 3
system_prompt: |
…
max_turns: 100
# -1(缺省)不限制轮数;设为正整数则限制单个 Task 的轮数。
max_turns: -1
model:
max_tokens: 32000
+33 -3
View File
@@ -1,10 +1,12 @@
/**
* Request-validation helper unit tests: positiveIntParam rejects trailing garbage, and
* optionalDateParam rejects impossible calendar dates (shape-only checks let these through).
* Request-validation helper unit tests: positiveIntParam rejects trailing garbage,
* optionalDateParam rejects impossible calendar dates (shape-only checks let these through),
* and optionalNumber enforces the agent runtime-parameter rule (integer, > 0 or exactly -1)
* used for max_turns and friends.
*/
import { describe, expect, it } from "vitest";
import type { Context } from "hono";
import { optionalDateParam, positiveIntParam } from "../src/http/validate.js";
import { optionalDateParam, optionalNumber, positiveIntParam } from "../src/http/validate.js";
import { HttpError } from "../src/http/errors.js";
/** Minimal Context stub exposing a single path parameter. */
@@ -65,3 +67,31 @@ describe("optionalDateParam", () => {
}
});
});
describe("optionalNumber with the agent runtime-parameter rule (integer, > 0 or -1)", () => {
// The exact rule agent-config PUT applies to maxTurns (and the other runtime numbers):
// -1 is the documented "unlimited" sentinel; every other non-positive value is rejected.
const rule = { integer: true, positiveOrMinusOne: true } as const;
it("accepts positive integers and the -1 unlimited sentinel", () => {
expect(optionalNumber({ maxTurns: 1 }, "maxTurns", rule)).toBe(1);
expect(optionalNumber({ maxTurns: 100 }, "maxTurns", rule)).toBe(100);
expect(optionalNumber({ maxTurns: -1 }, "maxTurns", rule)).toBe(-1);
});
it("returns undefined when the key is absent (PUT subsets leave it untouched)", () => {
expect(optionalNumber({}, "maxTurns", rule)).toBeUndefined();
});
it("rejects zero and negatives other than -1", () => {
for (const bad of [0, -2, -100]) {
expect(() => optionalNumber({ maxTurns: bad }, "maxTurns", rule)).toThrow(HttpError);
}
});
it("rejects non-integers and non-finite or non-number values", () => {
for (const bad of [1.5, -1.5, Number.NaN, Number.POSITIVE_INFINITY, "100", true, null]) {
expect(() => optionalNumber({ maxTurns: bad }, "maxTurns", rule)).toThrow(HttpError);
}
});
});