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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) │
|
||||
|
||||
@@ -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) │
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user