From 6e1c7cc28e311065ebba1118c0eb14e7cfe093e4 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Fri, 7 Aug 2026 18:53:03 +0800 Subject: [PATCH] feat(core,server,docs): default max_turns to -1 (unlimited) to avoid unexpected run cutoffs (#232) Co-authored-by: Claude Fable 5 --- packages/core/src/engine/context-engine.ts | 4 +-- packages/core/src/goal/goal-loop.ts | 8 ++--- packages/core/src/session.ts | 4 +-- packages/core/src/state/default-config.ts | 10 ++++-- packages/core/test/engine.test.ts | 8 +++-- packages/core/test/goal.test.ts | 14 +++++++++ packages/core/test/state.test.ts | 4 +++ packages/docs/content/agent-loop.en.md | 2 +- packages/docs/content/agent-loop.zh.md | 2 +- packages/docs/content/configuration.en.md | 5 +-- packages/docs/content/configuration.zh.md | 5 +-- packages/server/test/validate.test.ts | 36 ++++++++++++++++++++-- 12 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/core/src/engine/context-engine.ts b/packages/core/src/engine/context-engine.ts index b64905d..5c3c322 100644 --- a/packages/core/src/engine/context-engine.ts +++ b/packages/core/src/engine/context-engine.ts @@ -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; diff --git a/packages/core/src/goal/goal-loop.ts b/packages/core/src/goal/goal-loop.ts index 347d0e4..6a59441 100644 --- a/packages/core/src/goal/goal-loop.ts +++ b/packages/core/src/goal/goal-loop.ts @@ -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; } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2244a8b..7717db2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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; } diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts index 5242cf9..2766cad 100644 --- a/packages/core/src/state/default-config.ts +++ b/packages/core/src/state/default-config.ts @@ -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", diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 9a3708c..e226e29 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -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 () => { diff --git a/packages/core/test/goal.test.ts b/packages/core/test/goal.test.ts index 39aed1b..16937c1 100644 --- a/packages/core/test/goal.test.ts +++ b/packages/core/test/goal.test.ts @@ -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, diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index d5254ca..c9a026b 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -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); diff --git a/packages/docs/content/agent-loop.en.md b/packages/docs/content/agent-loop.en.md index 0c1e198..a0431ad 100644 --- a/packages/docs/content/agent-loop.en.md +++ b/packages/docs/content/agent-loop.en.md @@ -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) │ diff --git a/packages/docs/content/agent-loop.zh.md b/packages/docs/content/agent-loop.zh.md index 15a5ec6..bc3a0d4 100644 --- a/packages/docs/content/agent-loop.zh.md +++ b/packages/docs/content/agent-loop.zh.md @@ -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) │ diff --git a/packages/docs/content/configuration.en.md b/packages/docs/content/configuration.en.md index 08f64e6..ed638ab 100644 --- a/packages/docs/content/configuration.en.md +++ b/packages/docs/content/configuration.en.md @@ -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 diff --git a/packages/docs/content/configuration.zh.md b/packages/docs/content/configuration.zh.md index 7778014..e0c4868 100644 --- a/packages/docs/content/configuration.zh.md +++ b/packages/docs/content/configuration.zh.md @@ -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 diff --git a/packages/server/test/validate.test.ts b/packages/server/test/validate.test.ts index 8323a84..60005dd 100644 --- a/packages/server/test/validate.test.ts +++ b/packages/server/test/validate.test.ts @@ -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); + } + }); +});