fix(core): honor the -1 sentinels for max_turns and max_tokens (#56)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-24 16:18:38 +08:00
committed by GitHub
parent 6bbdac132f
commit c7465625a4
10 changed files with 66 additions and 10 deletions
+1
View File
@@ -78,6 +78,7 @@ describe("metaMaxTokens (meta-request budget tightened by the per-model cap)", (
expect(metaMaxTokens(300, 8000)).toBe(300); // ample cap: the small budget stays
expect(metaMaxTokens(300, 128)).toBe(128); // pinned below the budget: the cap binds
expect(metaMaxTokens(2048, 1024)).toBe(1024); // vision-describer budget, same rule
expect(metaMaxTokens(300, -1)).toBe(300); // -1 = uncapped: never tightens to -1 (issue #55 sibling)
});
});
+41
View File
@@ -331,6 +331,47 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
).toBe(true);
});
it("maxTurns -1 removes the cap instead of stopping before the first turn (issue #55)", async () => {
// Two tool-call turns followed by a final text turn: with the old `0 >= -1` guard the
// engine emitted the stop note without ever calling the LLM.
let calls = 0;
const llm: LLMInterface = {
async *streamGenerate() {
calls += 1;
if (calls <= 2) {
yield toolCall({
name: "exec_command",
arguments: JSON.stringify({ cmd: "true" }),
toolCallId: `c${calls}`,
stopReason: "completed",
});
} else {
yield assistantText("Done");
}
yield tokenUsage(emptyTokenCounts(), {
cache_read: 0,
cache_write: 0,
output: 1,
total: 1,
});
return { status: "completed" };
},
};
const environment = new Environment({
workspaceDir: workspace,
toolConfig: execCommandToolConfig(),
});
const engine = new ContextEngine({ llm, environment, maxTurns: -1 });
const all = await collectRun(engine, [userText("go")], allowAll);
expect(calls).toBe(3);
const texts = all
.filter((m) => isCompleteModelMessage(m) && m.payload.type === "text")
.map((m) => (m.payload as TextPayload).text);
expect(texts.some((t) => t.includes("reached max turns"))).toBe(false);
expect(texts.some((t) => t === "Done")).toBe(true);
});
it("max turns with pending tool outputs carries them over so the next run pairs the tool_call (issue #33)", async () => {
const received: OmniMessage[][] = [];
const llm: LLMInterface = {
+5
View File
@@ -1052,6 +1052,11 @@ describe("config helpers", () => {
expect("system_prompt" in minimal).toBe(false);
expect("max_tokens" in minimal).toBe(false);
expect("thinking_level" in minimal).toBe(false);
// max_tokens -1 (the config's "no cap" sentinel) stays OFF the wire — sent literally,
// providers reject a negative max_tokens with a 400.
const uncapped = buildUniConfig({ modelId: "m", tools: [], maxTokens: -1 });
expect("max_tokens" in uncapped).toBe(false);
});
it("omits tools when empty and never sets tool_choice (strict endpoints reject both)", () => {