diff --git a/src/agent/agent-loop.test.ts b/src/agent/agent-loop.test.ts index b273755a..f463830b 100644 --- a/src/agent/agent-loop.test.ts +++ b/src/agent/agent-loop.test.ts @@ -161,6 +161,75 @@ describe("AgentLoop end-to-end with mock LLM", () => { expect(llmRawCompletions).toEqual([{ attempt: 1, stepIndex: 0 }]); }); + it("pins RunTurnOptions.originalRequest into the prompt once its turn is dropped (F22)", async () => { + // The record the workers' briefs quote, now reaching the + // orchestrator's own prompt: a repair turn still sees the spec. + const registry = buildDefaultToolRegistry(); + const tails: string[] = []; + const loop = new AgentLoop({ + registry, + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: async () => + makeCompletion(JSON.stringify({ tool: "reply", args: { text: "done" } })), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + onEvent: (event) => { + if (event.type === "llm_event" && event.event.type === "prompt_captured") { + tails.push(event.event.tail); + } + }, + }); + const spec = `Build it: ${"detail ".repeat(30_000)}`; + const session = createEmptySessionState({ id: "s-request", workingDir }); + session.turns.push({ kind: "user", text: spec, at: 1 }); + session.turns.push({ kind: "assistant_reply", text: "built", at: 2 }); + await loop.runTurn(session, { + userMessage: "fix these bugs", + originalRequest: spec, + maxSteps: 2, + signal: new AbortController().signal, + }); + expect(tails).toHaveLength(1); + expect(tails[0]).toContain("### request"); + expect(tails[0]!.indexOf("### request")).toBeLessThan(tails[0]!.indexOf("### conversation")); + }); + + it("passes RunTurnOptions.reasoningEffort / maxOutputTokens to every completion (F20)", async () => { + const registry = buildDefaultToolRegistry(); + const seen: Array<{ effort?: string; cap?: number }> = []; + const loop = new AgentLoop({ + registry, + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: async (params) => { + seen.push({ + ...(params.reasoningEffort === undefined ? {} : { effort: params.reasoningEffort }), + ...(params.maxOutputTokens === undefined ? {} : { cap: params.maxOutputTokens }), + }); + return makeCompletion(JSON.stringify({ tool: "reply", args: { text: "done" } })); + }, + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + }); + const session = createEmptySessionState({ id: "s-effort", workingDir }); + await loop.runTurn(session, { + userMessage: "go", + reasoningEffort: "low", + maxOutputTokens: 12_000, + maxSteps: 2, + signal: new AbortController().signal, + }); + await loop.runTurn(session, { + userMessage: "again", + maxSteps: 2, + signal: new AbortController().signal, + }); + expect(seen).toEqual([{ effort: "low", cap: 12_000 }, {}]); + }); + it("keeps working past the leg length while the task is progressing", async () => { // The point of the change: `maxSteps` is a checkpoint, not the end // of the work. A task that is still getting usable results out of diff --git a/src/agent/agent-loop.ts b/src/agent/agent-loop.ts index c4d1c62f..03bc33df 100644 --- a/src/agent/agent-loop.ts +++ b/src/agent/agent-loop.ts @@ -8,7 +8,10 @@ import type { StreamChunk, } from "../llm/llama-server-client.js"; import type { SlotManager } from "../llm/slot-manager.js"; -import type { ToolCallTransport } from "../llm/provider/completion-types.js"; +import type { + ReasoningEffort, + ToolCallTransport, +} from "../llm/provider/completion-types.js"; import type { ToolCallAdapter } from "../llm/provider/adapters/tool-call-adapter.js"; import { PLAIN_INSTRUCT_PROFILE, @@ -142,6 +145,15 @@ export interface AgentLoopDependencies { * reflected without restarting the loop. */ contextWindow?: () => number | null; + /** + * The local worker leg's request-slot count as the server reported it + * (`SlotManager.observedPoolSize`), `null` until a `/props` answer has + * sized the pool. Read per step; it reaches the `### fusion` machine + * facts for an external llama-server whose `--parallel` the config + * cannot state. Moves once — when the pool is first observed — and the + * prefix moves with it, the same cost as a config write. + */ + liveWorkerSlots?: () => number | null; /** * The model server just revealed its real context window: a reply * stopped `context_window`-truncated after this many prompt + reply @@ -520,6 +532,26 @@ export interface RunTurnOptions { signal: AbortSignal; /** Optional new user message to append before stepping. */ userMessage?: string; + /** + * The operator's request behind this turn, as the runtime records it + * for the workers' briefs (`pickOriginalRequest`). Reaches every step's + * prompt as `### request` once the packer has dropped the user turn + * that carried it, so a repair turn still sees the spec. Absent in + * test / legacy wiring, where nothing is pinned. + */ + originalRequest?: string; + /** + * Reasoning effort for every completion of this turn, mapped per + * provider family by the body builder. A fusion worker's + * `workerReasoning`; absent, the provider's default. + */ + reasoningEffort?: ReasoningEffort; + /** + * Output ceiling for every completion of this turn, below the + * provider's own. A fusion worker's `workerMaxOutputTokens`; the + * truncation retry's per-step cap still wins over it. + */ + maxOutputTokens?: number; /** * Pin every completion of this turn to one configured provider id. * The step is built for that link's transport (via @@ -959,6 +991,16 @@ export class AgentLoop { // the per-request grammar — see `tool-roles.ts`. const toolRole: ToolRole = options.toolRole ?? (fusionOrchestratorTurn ? "orchestrator" : "full"); + // Claims need evidence, once per turn: a reply that reports a check + // nothing ran is held back and noticed the first time only + // (`claim-evidence.ts`); the second is delivered and marked. + let claimNoticeGiven = false; + const claimEvidence = { + noticed: () => claimNoticeGiven, + markNoticed: () => { + claimNoticeGiven = true; + }, + }; let reason: AgentLoopReason = "max_steps"; let stepsTaken = 0; @@ -1279,6 +1321,15 @@ export class AgentLoop { ...(options.userMessage !== undefined ? { userMessage: options.userMessage } : {}), + ...(options.originalRequest !== undefined + ? { originalRequest: options.originalRequest } + : {}), + ...(options.reasoningEffort !== undefined + ? { reasoningEffort: options.reasoningEffort } + : {}), + ...(options.maxOutputTokens !== undefined + ? { maxOutputTokens: options.maxOutputTokens } + : {}), }, { registry: this.deps.registry, @@ -1297,12 +1348,16 @@ export class AgentLoop { }, } : {}), + claimEvidence, slotManager: this.deps.slotManager, grammar: activeGrammar, profile: activeProfile, ...(this.deps.contextWindow ? { contextWindow: this.deps.contextWindow() } : {}), + ...(this.deps.liveWorkerSlots + ? { liveWorkerSlots: this.deps.liveWorkerSlots } + : {}), toolTransport: pinnedSlice?.toolTransport ?? this.deps.toolTransport ?? diff --git a/src/agent/claim-evidence.test.ts b/src/agent/claim-evidence.test.ts new file mode 100644 index 00000000..970e4927 --- /dev/null +++ b/src/agent/claim-evidence.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationTurn } from "../session/conversation-turn.js"; +import { + claimHasEvidence, + detectCheckClaims, + formatUnverifiedClaimNotice, + turnToolCalls, + unverifiedClaims, +} from "./claim-evidence.js"; + +const shell = (cmd: string, args: string[] = []) => ({ + tool: "os.shell.run", + args: { cmd, args }, +}); + +describe("detectCheckClaims", () => { + it("finds the claims the failing replies made, one per kind, in order", () => { + // Run 12: "Ran node --check on all JavaScript files (all passed)". + expect( + detectCheckClaims("Ran node --check on all JavaScript files (all passed)."), + ).toEqual([{ kind: "node-check", text: "node --check" }]); + expect(detectCheckClaims("All 12 tests pass. I verified the layout.")).toEqual([ + { kind: "tests", text: "tests pass" }, + { kind: "verified", text: "verified" }, + ]); + expect(detectCheckClaims("I ran the tests and lint passes; it builds cleanly.")).toEqual([ + { kind: "tests", text: "ran the tests" }, + { kind: "lint", text: "lint passes" }, + { kind: "build", text: "builds cleanly" }, + ]); + }); + + it("does not fire on ordinary prose", () => { + expect(detectCheckClaims("Here is the file. Run the tests when you like.")).toEqual([]); + expect(detectCheckClaims("The build step is next; nothing checked yet.")).toEqual([]); + }); +}); + +describe("claimHasEvidence", () => { + it("accepts a shell command containing the claimed check", () => { + const nodeCheck = { kind: "node-check" as const, text: "node --check" }; + expect(claimHasEvidence(nodeCheck, [shell("node", ["--check", "a.js"])])).toBe(true); + expect(claimHasEvidence(nodeCheck, [shell("node -c js/a.js")])).toBe(true); + expect(claimHasEvidence(nodeCheck, [shell("node", ["js/a.js"])])).toBe(false); + expect(claimHasEvidence(nodeCheck, [shell("ls")])).toBe(false); + const tests = { kind: "tests" as const, text: "tests pass" }; + expect(claimHasEvidence(tests, [shell("npx", ["vitest", "run"])])).toBe(true); + expect(claimHasEvidence(tests, [shell("npm test")])).toBe(true); + expect(claimHasEvidence(tests, [shell("cat", ["a.js"])])).toBe(false); + }); + + it("accepts any verify.* call for any claim", () => { + for (const kind of ["node-check", "tests", "verified", "lint", "build"] as const) { + expect( + claimHasEvidence({ kind, text: "x" }, [{ tool: "verify.syntax", args: {} }]), + ).toBe(true); + } + }); + + it("reads a bare 'verified' as backed by any command the turn ran", () => { + const verified = { kind: "verified" as const, text: "verified" }; + expect(claimHasEvidence(verified, [shell("ls")])).toBe(true); + expect(claimHasEvidence(verified, [{ tool: "os.fs.read", args: {} }])).toBe(false); + }); +}); + +describe("unverifiedClaims / turnToolCalls", () => { + it("returns only the claims nothing backs", () => { + expect( + unverifiedClaims("tests pass and node --check passed", [shell("node --check a.js")]), + ).toEqual([{ kind: "tests", text: "tests pass" }]); + expect(unverifiedClaims("tests pass", [{ tool: "verify.run", args: {} }])).toEqual([]); + }); + + it("reads this turn's calls: everything after the last user turn", () => { + const turns: ConversationTurn[] = [ + { kind: "user", text: "build it", at: 1 }, + { kind: "assistant_tool_call", tool: "os.shell.run", args: { cmd: "node --check a.js" }, at: 2 }, + { kind: "tool_result", tool: "os.shell.run", status: "ok", summary: "", at: 3 }, + { kind: "assistant_reply", text: "done", at: 4 }, + { kind: "user", text: "now fix it", at: 5 }, + { kind: "assistant_tool_call", tool: "os.fs.read", args: { path: "a.js" }, at: 6 }, + { kind: "tool_result", tool: "os.fs.read", status: "ok", summary: "", at: 7 }, + ]; + expect(turnToolCalls(turns)).toEqual([{ tool: "os.fs.read", args: { path: "a.js" } }]); + // The earlier turn's node --check is not evidence for this turn. + expect(unverifiedClaims("node --check passed", turnToolCalls(turns))).toHaveLength(1); + }); + + it("names the claim and both exits in the notice", () => { + const notice = formatUnverifiedClaimNotice([{ kind: "tests", text: "tests pass" }]); + expect(notice).toContain('claims "tests pass"'); + expect(notice).toContain("no such check ran this turn"); + expect(notice).toContain("`verify.run`"); + expect(notice).toContain("`os.shell.run`"); + expect(notice).toContain("remove the claim"); + }); +}); diff --git a/src/agent/claim-evidence.ts b/src/agent/claim-evidence.ts new file mode 100644 index 00000000..1d2af649 --- /dev/null +++ b/src/agent/claim-evidence.ts @@ -0,0 +1,159 @@ +import type { ConversationTurn } from "../session/conversation-turn.js"; + +/** + * Claims need evidence. + * + * A final `reply` that says a check ran — "ran node --check on all + * files (all passed)", "tests pass", "verified" — is read by the + * operator as a fact about this turn. Three runs showed it was not: + * one reply reported every JavaScript file checked after a one-file + * `node --check`, another "Syntax: all passed" from the same command, + * and a 12B model claimed `node --check` passed with no shell call at + * all. Nothing compared the claim with the turn's tool calls. + * + * This does. A claim is matched against the turn's calls: a shell + * command that contains the claimed check, or any `verify.*` call, + * counts as evidence. A claim with none earns one `### notice` and one + * more step — the same shape as the invented-transcript rejection: the + * reply is not delivered, the model is told why, and the exit is named + * (run the check, or drop the claim). Once per turn: a second reply + * that still claims is delivered, and the trace marks it. + * + * It is a heuristic and is held to warn-once. The patterns are the + * ones the failing replies used; a reply that mentions tests the + * operator ran gets a needless notice at worst, never a blocked turn. + */ + +export type CheckClaimKind = "node-check" | "tests" | "verified" | "lint" | "build"; + +export interface CheckClaim { + kind: CheckClaimKind; + /** The words in the reply that made the claim, as written. */ + text: string; +} + +/** A tool call made this turn, as much of it as the evidence check reads. */ +export interface TurnToolCall { + tool: string; + args: Record; +} + +const CLAIM_PATTERNS: ReadonlyArray<{ kind: CheckClaimKind; re: RegExp }> = [ + { kind: "node-check", re: /node\s+--check/i }, + { kind: "tests", re: /\btests?\s+(?:pass|passed)\b/i }, + { kind: "tests", re: /\bran\s+(?:the\s+)?tests\b/i }, + { kind: "verified", re: /\bverified\b/i }, + { kind: "lint", re: /\blint(?:ed)?\s+passes\b/i }, + { kind: "build", re: /\bbuilds?\s+(?:cleanly|passes)\b/i }, +]; + +/** + * What a shell command must contain to stand as evidence for a claim. + * `verified` names no particular command, so any command the turn ran + * is taken as the thing that was verified. + */ +const EVIDENCE: Readonly> = { + "node-check": /\bnode(?:\.exe)?\s+(?:[^\s]+\s+)*?(?:--check|-c)\b/i, + tests: /\b(?:tests?|vitest|jest|mocha|pytest|unittest|spec|cargo\s+test|go\s+test)\b/i, + verified: /\S/, + lint: /\b(?:lint|eslint|ruff|flake8|pylint|clippy|golangci-lint|biome)\b/i, + build: /\b(?:build|tsc|make|cargo\s+build|go\s+build|webpack|vite|esbuild|compile|gradle|mvn)\b/i, +}; + +/** The claims a reply makes, one per kind, in order of appearance. */ +export function detectCheckClaims(replyText: string): CheckClaim[] { + const found = new Map(); + for (const { kind, re } of CLAIM_PATTERNS) { + const match = re.exec(replyText); + if (match === null) continue; + const existing = found.get(kind); + if (existing === undefined || match.index < existing.at) { + found.set(kind, { claim: { kind, text: match[0] }, at: match.index }); + } + } + return [...found.values()] + .sort((a, b) => a.at - b.at) + .map((entry) => entry.claim); +} + +/** The command line an `os.shell.run` call would run, or `null`. */ +function shellCommandLine(call: TurnToolCall): string | null { + if (call.tool !== "os.shell.run") return null; + const cmd = typeof call.args.cmd === "string" ? call.args.cmd : ""; + const args = Array.isArray(call.args.args) + ? call.args.args.filter((a): a is string => typeof a === "string") + : []; + const line = [cmd, ...args].join(" ").trim(); + return line.length > 0 ? line : null; +} + +/** Whether `calls` hold a check that stands for `claim`. */ +export function claimHasEvidence( + claim: CheckClaim, + calls: readonly TurnToolCall[], +): boolean { + for (const call of calls) { + if (call.tool.startsWith("verify.")) return true; + const line = shellCommandLine(call); + if (line !== null && EVIDENCE[claim.kind].test(line)) return true; + } + return false; +} + +/** The claims in `replyText` that nothing in `calls` backs. */ +export function unverifiedClaims( + replyText: string, + calls: readonly TurnToolCall[], +): CheckClaim[] { + return detectCheckClaims(replyText).filter( + (claim) => !claimHasEvidence(claim, calls), + ); +} + +/** + * The tool calls of the turn now running: everything after the last + * `user` turn in the transcript. A turn's own batch (the calls emitted + * alongside the reply) is appended by the caller — they run before the + * reply under the tail-terminal barrier, so they count. + */ +export function turnToolCalls( + turns: readonly ConversationTurn[], +): TurnToolCall[] { + let start = 0; + for (let i = turns.length - 1; i >= 0; i -= 1) { + if (turns[i]?.kind === "user") { + start = i + 1; + break; + } + } + const calls: TurnToolCall[] = []; + for (const turn of turns.slice(start)) { + if (turn.kind === "assistant_tool_call") { + calls.push({ tool: turn.tool, args: turn.args }); + } + } + return calls; +} + +function quoteClaims(claims: readonly CheckClaim[]): string { + return claims.map((claim) => `"${claim.text}"`).join(", "); +} + +/** + * The next-step notice. Names the claim as written, says nothing ran, + * and names both exits — the two tools that produce evidence, and + * dropping the claim — so the model does not answer with the same + * sentence rephrased. + */ +export function formatUnverifiedClaimNotice( + claims: readonly CheckClaim[], +): string { + return `Your reply claims ${quoteClaims(claims)} but no such check ran this turn. Run it (\`verify.run\`, \`os.shell.run\`) or remove the claim, then reply again.`; +} + +/** The tool result that stands in for the reply that was not delivered. */ +export function formatUnverifiedClaimRefusal( + claims: readonly CheckClaim[], +): string { + return `not delivered: the reply claims ${quoteClaims(claims)} but no matching check ran this turn. Run the check (\`verify.run\`, \`os.shell.run\`) or remove the claim, then reply again.`; +} diff --git a/src/agent/fusion-orchestrator-mode.test.ts b/src/agent/fusion-orchestrator-mode.test.ts index 0080e4d8..08182293 100644 --- a/src/agent/fusion-orchestrator-mode.test.ts +++ b/src/agent/fusion-orchestrator-mode.test.ts @@ -216,3 +216,41 @@ describe("wouldRefuse / refusedToolNames — the gate's verdict ahead of dispatc ]); }); }); + +describe("wouldRefuse (the shared predicate)", () => { + // One predicate behind the dispatch gate, the batch trim and the + // per-request grammar: if they disagreed, the trim could keep a call + // the gate then refuses — which is exactly what happened in run 14. + const ctx = { registry: REGISTRY }; + + it("answers the same as the dispatch gate for every tool", () => { + for (const tool of Object.keys({ + "mcp.notion.search": 0, + "os.fs.read": 0, + "os.fs.write": 0, + "os.shell.run": 0, + "fusion.delegate": 0, + reply: 0, + finish: 0, + "os.fs.wirte": 0, + })) { + expect(wouldRefuse(tool, ctx)).toBe( + !checkFusionOrchestrator(tool, REGISTRY, BEFORE).allowed, + ); + } + }); + + it("never refuses the fan-out, the terminals, reads or unknown names", () => { + expect(wouldRefuse("fusion.delegate", ctx)).toBe(false); + expect(wouldRefuse("reply", ctx)).toBe(false); + expect(wouldRefuse("finish", ctx)).toBe(false); + expect(wouldRefuse("os.fs.read", ctx)).toBe(false); + expect(wouldRefuse("os.fs.wirte", ctx)).toBe(false); + }); + + it("refuses mutations and every MCP tool, whatever it claims", () => { + expect(wouldRefuse("os.fs.write", ctx)).toBe(true); + expect(wouldRefuse("os.shell.run", ctx)).toBe(true); + expect(wouldRefuse("mcp.notion.search", ctx)).toBe(true); + }); +}); diff --git a/src/agent/fusion-orchestrator-mode.ts b/src/agent/fusion-orchestrator-mode.ts index 16be55a2..cd361345 100644 --- a/src/agent/fusion-orchestrator-mode.ts +++ b/src/agent/fusion-orchestrator-mode.ts @@ -102,6 +102,28 @@ function mutates(tool: string, registry: Pick): boolean { return !registry.get(tool).readonly; } +/** What the gate needs to answer "would this call be refused". */ +export interface FusionGateContext { + registry: Pick; +} + +/** + * The one predicate behind the gate: would an ORCHESTRATOR turn refuse + * `toolName`? Shared by `checkFusionOrchestrator` (the refusal at + * dispatch), the step executor's batch trim (which must not keep a call + * the gate is about to refuse) and the per-request grammar (which must + * not let a local orchestrator generate one). One predicate, so the + * three can never disagree about which call survives. + * + * The verdict does not depend on turn state — `delegations` only shapes + * the refusal text — so the context is the registry alone. + */ +export function wouldRefuse(toolName: string, ctx: FusionGateContext): boolean { + if (TERMINAL_TOOLS.has(toolName) || toolName === DELEGATE_TOOL) return false; + if (!ctx.registry.has(toolName)) return false; + return mutates(toolName, ctx.registry); +} + /** * Decide whether `tool` may run on the orchestrator's own turn. * @@ -115,38 +137,16 @@ export function checkFusionOrchestrator( registry: Pick, state: FusionOrchestratorState, ): FusionOrchestratorVerdict { - if (TERMINAL_TOOLS.has(tool) || tool === DELEGATE_TOOL) { - return { allowed: true }; - } - if (!registry.has(tool)) return { allowed: true }; - if (!mutates(tool, registry)) return { allowed: true }; + if (!wouldRefuse(tool, { registry })) return { allowed: true }; return { allowed: false, refusal: refusalFor(tool, state) }; } -/** What `wouldRefuse` needs: the registry the gate reads mutability off. */ -export interface FusionGateContext { - registry: Pick; -} - /** - * Would the orchestrator gate refuse `tool` on this turn, regardless of - * what has been delegated? The verdict does not depend on the turn's - * ledger — the count only shapes the refusal text — so this is the same - * answer `checkFusionOrchestrator` gives at dispatch, computable before - * the model has emitted anything. That is what lets a local - * orchestrator's per-request grammar drop the tools the gate would - * refuse (the descriptors stay in the prompt; only the sampler's - * vocabulary shrinks), and a batch trim skip calls that would never run. + * The subset of `names` the gate would refuse — see `wouldRefuse`. What + * lets a local orchestrator's per-request grammar drop the tools the + * gate would refuse (the descriptors stay in the prompt; only the + * sampler's vocabulary shrinks). */ -export function wouldRefuse(tool: string, ctx: FusionGateContext): boolean { - return !checkFusionOrchestrator( - tool, - ctx.registry, - emptyFusionOrchestratorState(), - ).allowed; -} - -/** The subset of `names` the gate would refuse — see `wouldRefuse`. */ export function refusedToolNames( names: Iterable, ctx: FusionGateContext, diff --git a/src/agent/plan-mode.test.ts b/src/agent/plan-mode.test.ts index a51442d1..2c5bf77e 100644 --- a/src/agent/plan-mode.test.ts +++ b/src/agent/plan-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { checkPlanMode } from "./plan-mode.js"; +import { checkPlanMode, wouldRefuse } from "./plan-mode.js"; import { ToolRegistry, type ToolDefinition } from "../tools/tool-registry.js"; function tool(name: string, readonly: boolean): ToolDefinition { @@ -77,3 +77,28 @@ describe("checkPlanMode", () => { expect(refusal?.details).toMatchObject({ plan_mode: true }); }); }); + +describe("wouldRefuse (the shared predicate)", () => { + // The batch trim asks this before it picks a survivor, so the call it + // keeps is never one the dispatch gate is about to refuse. + const registry = registryWith( + tool("os.fs.read", true), + tool("os.fs.write", false), + tool("reply", false), + tool("finish", false), + ); + const ctx = { registry }; + + it("answers the same as checkPlanMode for every tool", () => { + for (const name of ["os.fs.read", "os.fs.write", "reply", "finish", "os.fs.raed"]) { + expect(wouldRefuse(name, ctx)).toBe(!checkPlanMode(name, registry).allowed); + } + }); + + it("refuses only registered mutations", () => { + expect(wouldRefuse("os.fs.write", ctx)).toBe(true); + expect(wouldRefuse("os.fs.read", ctx)).toBe(false); + expect(wouldRefuse("reply", ctx)).toBe(false); + expect(wouldRefuse("os.fs.raed", ctx)).toBe(false); + }); +}); diff --git a/src/agent/plan-mode.ts b/src/agent/plan-mode.ts index 29f4beec..8105155e 100644 --- a/src/agent/plan-mode.ts +++ b/src/agent/plan-mode.ts @@ -60,13 +60,28 @@ export function checkPlanMode( tool: string, registry: Pick, ): PlanModeVerdict { - if (TERMINAL_TOOLS.has(tool)) return { allowed: true }; - // `has` before `get`, because `get` throws for an unknown name. - if (!registry.has(tool)) return { allowed: true }; - if (registry.get(tool).readonly) return { allowed: true }; + if (!wouldRefuse(tool, { registry })) return { allowed: true }; return { allowed: false, refusal: refusalFor(tool) }; } +/** What the gate needs to answer "would this call be refused". */ +export interface PlanModeContext { + registry: Pick; +} + +/** + * The predicate behind the gate: would plan mode refuse `toolName`? + * Shared with the step executor's batch trim so the call it keeps is + * never one this gate is about to refuse — same contract as the fusion + * gate's `wouldRefuse`. + */ +export function wouldRefuse(toolName: string, ctx: PlanModeContext): boolean { + if (TERMINAL_TOOLS.has(toolName)) return false; + // `has` before `get`, because `get` throws for an unknown name. + if (!ctx.registry.has(toolName)) return false; + return !ctx.registry.get(toolName).readonly; +} + /** * What the model is told. * diff --git a/src/agent/step-events.ts b/src/agent/step-events.ts index 01cf50de..f8e0e66d 100644 --- a/src/agent/step-events.ts +++ b/src/agent/step-events.ts @@ -148,6 +148,12 @@ export type StepEvent = kept: string; /** Tool names of dropped calls in original batch-index order. */ dropped: string[]; + /** + * Tool names dropped because the turn's policy (plan mode, the + * fusion orchestrator gate) would have refused them — not to be + * retried. Omitted when none. Disjoint from `dropped`. + */ + refused?: string[]; /** Canonical trim cause. New reasons may be added over time. */ reason: "approval-gated-batched"; } diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 033fbff9..d3b5dac7 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -10,9 +10,13 @@ import { } from "../llm/model-profile.js"; import { REPAIR_MAX_TOKENS, + TRIM_REFUSED_BY_FUSION_GATE, + TRIM_REFUSED_BY_PLAN_MODE, detectFabricatedToolTranscript, formatFabricatedTranscriptNotice, type LlmStreamParams, + trimBatchToFirstApprovalGated, + turnPolicyForTrim, type StepApprovalPostureSource, type StepEvent, } from "./step-executor.js"; @@ -1819,7 +1823,9 @@ describe("executeStep approval-gated batches that would not prompt", () => { expect(outcome.toolCalls).toHaveLength(1); expect(log).toEqual(["start os.fs.write a", "end os.fs.write a"]); expect(events.filter((e) => e.type === "batch_trimmed")).toHaveLength(1); - expect(outcome.trimmedBatchNotice).toContain("dropped the rest"); + expect(outcome.trimmedBatchNotice).toContain( + "Dropped from the batch — retry", + ); }); it("still trims when no approval posture is wired", async () => { @@ -4635,3 +4641,426 @@ describe("executeStep — the structured prompt on a native-tools link", () => { expect(params).not.toHaveProperty("messages"); }); }); + +describe("batch trim consults the turn policy (F8)", () => { + // Run 14, attempt 0, first step: the orchestrator emitted + // `[os.shell.run mkdir, fusion.delegate]`. The trim kept `mkdir` (first + // approval-gated call in emit order), the fusion gate then refused it, + // and the delegation — nine minutes of generation — was dropped for a + // retry. The survivor must be a call that can run. + const grammarsDir = join(process.cwd(), "grammars"); + + function makeRegistry() { + const registry = new ToolRegistry(); + const define = (name: string, readonly: boolean) => + registry.register({ + name, + description: name, + readonly, + async run(args) { + return compressToolResult({ + tool: name, + status: "ok", + output: `${name} ran (${JSON.stringify(args)})`, + }); + }, + }); + define("os.fs.read", true); + define("os.fs.write", false); + define("os.fs.edit", false); + define("os.shell.run", false); + define("fusion.delegate", false); + return registry; + } + + async function run( + body: string, + policy: { isPlanMode?: () => boolean; isFusionOrchestrator?: () => boolean }, + ) { + const events: StepEvent[] = []; + const registry = makeRegistry(); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const session = createEmptySessionState({ id: "s-f8", workingDir: "/w" }); + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "x", + }, + { + registry, + ...policy, + ...(policy.isFusionOrchestrator + ? { fusionState: () => ({ delegations: 0 }) } + : {}), + slotManager: new SlotManager(2), + llmComplete: async () => ({ + content: body, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }), + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + onEvent: (ev) => events.push(ev), + }, + ); + return { outcome, events }; + } + + it("keeps fusion.delegate on an orchestrator turn and names the refused call", async () => { + const body = JSON.stringify([ + { tool: "os.shell.run", args: { cmd: "mkdir", args: ["-p", "js"] } }, + { + tool: "fusion.delegate", + args: { tasks: [{ id: "a", title: "t", instructions: "i" }] }, + }, + ]); + const { outcome, events } = await run(body, { + isFusionOrchestrator: () => true, + }); + expect(outcome.toolCalls.map((c) => c.tool)).toEqual(["fusion.delegate"]); + expect(outcome.toolResults[0]!.status).toBe("ok"); + const trim = events.find((e) => e.type === "batch_trimmed"); + expect(trim).toMatchObject({ + kept: "fusion.delegate", + dropped: [], + refused: ["os.shell.run"], + }); + expect(outcome.trimmedBatchNotice).toContain("`os.shell.run`"); + expect(outcome.trimmedBatchNotice).toContain("refused by the fusion gate"); + expect(outcome.trimmedBatchNotice).toContain("do not retry"); + expect(outcome.trimmedBatchNotice).not.toContain("Dropped from the batch — retry"); + }); + + it("prefers the fan-out over an earlier runnable write on an orchestrator turn", async () => { + // Even a call the gate would not refuse (none here — both mutate — + // but the preference is what is under test) yields to the fan-out. + const trim = trimBatchToFirstApprovalGated( + { + kind: "batch", + calls: [ + { tool: "os.fs.read", args: { path: "a" } }, + { tool: "fusion.delegate", args: { tasks: [] } }, + { tool: "os.fs.write", args: { path: "b", content: "" } }, + ], + }, + { preferTool: "fusion.delegate" }, + ); + expect(trim?.kept.tool).toBe("fusion.delegate"); + expect(trim?.dropped.map((c) => c.tool)).toEqual(["os.fs.read", "os.fs.write"]); + expect(trim?.refused).toEqual([]); + }); + + it("falls back to the first gated call when every gated call would be refused", async () => { + // Plan mode: `[write, edit, read]`. Nothing gated can run; the first + // is kept so the gate's own refusal — the instruction — is what the + // model reads, the second is named as refused, the read as a retry. + const body = JSON.stringify([ + { tool: "os.fs.write", args: { path: "a", content: "x" } }, + { tool: "os.fs.edit", args: { path: "b", oldString: "x", newString: "y" } }, + { tool: "os.fs.read", args: { path: "c" } }, + ]); + const { outcome, events } = await run(body, { isPlanMode: () => true }); + expect(outcome.toolCalls.map((c) => c.tool)).toEqual(["os.fs.write"]); + expect(outcome.toolResults[0]!.status).toBe("error"); + expect(outcome.toolResults[0]!.details).toMatchObject({ plan_mode: true }); + expect(events.find((e) => e.type === "batch_trimmed")).toMatchObject({ + kept: "os.fs.write", + dropped: ["os.fs.read"], + refused: ["os.fs.edit"], + }); + expect(outcome.trimmedBatchNotice).toContain("`os.fs.edit` (refused by plan mode)"); + expect(outcome.trimmedBatchNotice).toContain("Dropped from the batch — retry: `os.fs.read`"); + }); + + it("is byte-identical to the old trim when no policy is active", async () => { + const body = JSON.stringify([ + { tool: "os.fs.write", args: { path: "a", content: "x" } }, + { tool: "os.fs.edit", args: { path: "b", oldString: "x", newString: "y" } }, + ]); + const { outcome, events } = await run(body, {}); + expect(outcome.toolCalls.map((c) => c.tool)).toEqual(["os.fs.write"]); + const trim = events.find((e) => e.type === "batch_trimmed"); + expect(trim).toMatchObject({ kept: "os.fs.write", dropped: ["os.fs.edit"] }); + expect(trim).not.toHaveProperty("refused"); + expect(outcome.trimmedBatchNotice).not.toContain("refused"); + }); + + it("turnPolicyForTrim names the gate that would refuse, plan mode first", () => { + const registry = makeRegistry(); + const both = turnPolicyForTrim({ + registry, + isPlanMode: () => true, + isFusionOrchestrator: () => true, + }); + expect(both.preferTool).toBe("fusion.delegate"); + expect(both.refusedBy?.("os.fs.write")).toBe(TRIM_REFUSED_BY_PLAN_MODE); + expect(both.refusedBy?.("os.fs.read")).toBeNull(); + expect(both.refusedBy?.("fusion.delegate")).toBe(TRIM_REFUSED_BY_PLAN_MODE); + const orchestrator = turnPolicyForTrim({ + registry, + isFusionOrchestrator: () => true, + }); + expect(orchestrator.refusedBy?.("os.shell.run")).toBe(TRIM_REFUSED_BY_FUSION_GATE); + expect(orchestrator.refusedBy?.("fusion.delegate")).toBeNull(); + expect(turnPolicyForTrim({ registry })).toEqual({}); + }); +}); + +describe("claims need evidence (F9b)", () => { + // A reply that says a check ran, with no matching call this turn, is + // held back once with a notice; the second time it is delivered and + // marked. The forced final step never holds a reply. + const grammarsDir = join(process.cwd(), "grammars"); + + function makeRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "os.shell.run", + description: "shell", + readonly: false, + async run(args) { + return compressToolResult({ + tool: "os.shell.run", + status: "ok", + output: `$ ${String(args.cmd)}\nexit: 0`, + }); + }, + }); + registry.register(replyTool); + return registry; + } + + async function run( + body: string, + options: { + turns?: Array<{ tool: string; args: Record }>; + noticed?: boolean; + terminalOnly?: boolean; + claimEvidence?: false; + } = {}, + ) { + const registry = makeRegistry(); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const session = createEmptySessionState({ id: "s-f9", workingDir: "/w" }); + session.turns.push({ kind: "user", text: "check the files", at: 1 }); + for (const call of options.turns ?? []) { + session.turns.push({ kind: "assistant_tool_call", ...call, at: 2 }); + session.turns.push({ kind: "tool_result", tool: call.tool, status: "ok", summary: "ok", at: 3 }); + } + let noticed = options.noticed ?? false; + let marks = 0; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "check the files", + ...(options.terminalOnly ? { terminalOnly: true } : {}), + }, + { + registry, + ...(options.claimEvidence === false + ? {} + : { + claimEvidence: { + noticed: () => noticed, + markNoticed: () => { + noticed = true; + marks += 1; + }, + }, + }), + slotManager: new SlotManager(2), + llmComplete: async () => ({ + content: body, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }), + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + }, + ); + return { outcome, marks: () => marks }; + } + + const CLAIM = JSON.stringify([ + { tool: "reply", args: { text: "Ran node --check on all JavaScript files (all passed)." } }, + ]); + + it("holds a reply that claims a check nothing ran, once, with a notice", async () => { + const { outcome, marks } = await run(CLAIM); + expect(outcome.terminal).toBeNull(); + expect(outcome.toolCalls.map((c) => c.tool)).toEqual(["reply"]); + expect(outcome.toolResults[0]!.status).toBe("error"); + expect(outcome.toolResults[0]!.summary).toContain("not delivered"); + expect(outcome.toolResults[0]!.details).toMatchObject({ + notDelivered: true, + unverifiedClaims: ["node --check"], + }); + expect(outcome.trimmedBatchNotice).toContain('Your reply claims "node --check"'); + expect(outcome.trimmedBatchNotice).toContain("no such check ran this turn"); + expect(marks()).toBe(1); + // The transcript reads the held reply as a call that never delivered. + const last = outcome.nextSession.turns[outcome.nextSession.turns.length - 1]; + expect(last?.kind).toBe("tool_result"); + }); + + it("delivers the reply when a shell call this turn ran the claimed check", async () => { + const { outcome, marks } = await run(CLAIM, { + turns: [{ tool: "os.shell.run", args: { cmd: "node", args: ["--check", "js/a.js"] } }], + }); + expect(outcome.terminal).toBe("turn"); + expect(outcome.toolResults[0]!.status).toBe("ok"); + expect(outcome.toolResults[0]!.details).not.toHaveProperty("unverifiedClaims"); + expect(outcome.trimmedBatchNotice).toBeUndefined(); + expect(marks()).toBe(0); + }); + + it("delivers a second claiming reply and marks it in the result details", async () => { + const { outcome, marks } = await run(CLAIM, { noticed: true }); + expect(outcome.terminal).toBe("turn"); + expect(outcome.toolResults[0]!.status).toBe("ok"); + expect(outcome.toolResults[0]!.details).toMatchObject({ + unverifiedClaims: ["node --check"], + }); + expect(marks()).toBe(0); + }); + + it("never holds the forced final step's reply, but marks it", async () => { + const { outcome, marks } = await run(CLAIM, { terminalOnly: true }); + expect(outcome.terminal).toBe("turn"); + expect(outcome.toolResults[0]!.details).toMatchObject({ + unverifiedClaims: ["node --check"], + }); + expect(marks()).toBe(0); + }); + + it("does nothing when the loop passes no claim state", async () => { + const { outcome } = await run(CLAIM, { claimEvidence: false }); + expect(outcome.terminal).toBe("turn"); + expect(outcome.toolResults[0]!.details).not.toHaveProperty("unverifiedClaims"); + }); +}); + +describe("the operator's request reaches the prompt (F22)", () => { + it("renders ### request when the step context carries it and the packer dropped its turn", async () => { + const grammarsDir = join(process.cwd(), "grammars"); + const registry = new ToolRegistry(); + registry.register(replyTool); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const spec = `Build it: ${"detail ".repeat(30_000)}`; + const session = createEmptySessionState({ id: "s-f22", workingDir: "/w" }); + session.turns.push({ kind: "user", text: spec, at: 1 }); + session.turns.push({ kind: "assistant_reply", text: "built", at: 2 }); + session.turns.push({ kind: "user", text: "fix these bugs", at: 3 }); + const run = (originalRequest?: string) => + executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "fix these bugs", + ...(originalRequest === undefined ? {} : { originalRequest }), + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async () => ({ + content: JSON.stringify([{ tool: "reply", args: { text: "ok" } }]), + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }), + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + }, + ); + const pinned = await run(spec); + expect(pinned.prompt.droppedTurns).toBeGreaterThan(0); + expect(pinned.prompt.tail).toContain("### request"); + expect(pinned.prompt.tail.indexOf("### request")).toBeLessThan( + pinned.prompt.tail.indexOf("### conversation"), + ); + const bare = await run(); + expect(bare.prompt.tail).not.toContain("### request"); + }); +}); + +describe("the turn's reasoning effort and output ceiling reach the request (F20)", () => { + it("rides on llmParams from the step context; the per-step cap still wins", async () => { + const grammarsDir = join(process.cwd(), "grammars"); + const registry = new ToolRegistry(); + registry.register(replyTool); + const grammar = await buildGrammar(PLAIN_INSTRUCT_PROFILE, grammarsDir); + const seen: Array<{ reasoningEffort?: string; maxOutputTokens?: number; maxTokens?: number }> = []; + const run = (over: { reasoningEffort?: "low"; maxOutputTokens?: number; maxTokens?: number }) => + executeStep( + { + session: createEmptySessionState({ id: "s-f20", workingDir: "/w" }), + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "x", + ...over, + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async (params) => { + seen.push({ + ...(params.reasoningEffort === undefined ? {} : { reasoningEffort: params.reasoningEffort }), + ...(params.maxOutputTokens === undefined ? {} : { maxOutputTokens: params.maxOutputTokens }), + ...(params.maxTokens === undefined ? {} : { maxTokens: params.maxTokens }), + }); + return { + content: JSON.stringify([{ tool: "reply", args: { text: "ok" } }]), + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; + }, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + }, + ); + await run({ reasoningEffort: "low", maxOutputTokens: 12_000 }); + await run({ maxOutputTokens: 12_000, maxTokens: 32_000 }); + await run({}); + expect(seen).toEqual([ + { reasoningEffort: "low", maxOutputTokens: 12_000 }, + { maxOutputTokens: 12_000, maxTokens: 32_000 }, + {}, + ]); + }); +}); diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index aa63a400..64bf29aa 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -21,6 +21,15 @@ import { resourceClassFor, type BatchApprovalPosture, } from "./tool-resource-class.js"; +import { wouldRefuse as planModeWouldRefuse } from "./plan-mode.js"; +import { wouldRefuse as fusionGateWouldRefuse } from "./fusion-orchestrator-mode.js"; +import { + formatUnverifiedClaimNotice, + formatUnverifiedClaimRefusal, + turnToolCalls, + unverifiedClaims, + type CheckClaim, +} from "./claim-evidence.js"; import { createStreamParser } from "../llm/grammar/stream-parser.js"; import { buildGrammarForTools } from "../llm/grammar/build-grammar.js"; import { refusedToolNames } from "./fusion-orchestrator-mode.js"; @@ -100,6 +109,7 @@ import { } from "../llm/model-profile.js"; import type { PromptMessages, + ReasoningEffort, ResponseFormatJsonSchema, ToolCallTransport, } from "../llm/provider/completion-types.js"; @@ -149,6 +159,14 @@ export interface LlmStreamParams { * failure mode (see `REPAIR_MAX_TOKENS` and the call-site comment). */ maxTokens?: number; + /** + * The turn's output ceiling (`RunTurnOptions.maxOutputTokens`), below + * the per-step `maxTokens` above and above the provider's own. A + * fusion worker's `workerMaxOutputTokens` rides here. + */ + maxOutputTokens?: number; + /** The turn's reasoning effort (`RunTurnOptions.reasoningEffort`). */ + reasoningEffort?: ReasoningEffort; /** OpenAI tools payload — set when `toolTransport === "native_tools"`. */ tools?: ReadonlyArray>; toolChoice?: unknown; @@ -201,6 +219,12 @@ export interface StepDependencies { isFusionOrchestrator?: () => boolean; fusionState?: () => import("./fusion-orchestrator-mode.js").FusionOrchestratorState; onDelegated?: (result: CompressedToolResult) => void; + /** + * Claims need evidence (`claim-evidence.ts`). Per-turn state held by + * the loop: whether this turn has already been told once that a reply + * claimed a check that never ran. Absent ⇒ replies are never held. + */ + claimEvidence?: { noticed: () => boolean; markNoticed: () => void }; slotManager: SlotManager; llmComplete: (params: LlmStreamParams) => Promise; /** @@ -224,6 +248,12 @@ export interface StepDependencies { * guessed window is worse than one that admits it has none. */ contextWindow?: number | null; + /** + * The local worker leg's request-slot count as the server reported + * it, `null` until observed — forwarded to `buildPrompt` for the + * `### fusion` machine facts. See `AgentLoopDeps.liveWorkerSlots`. + */ + liveWorkerSlots?: () => number | null; /** Effective transport for this runtime (grammar vs native OpenAI tools). */ toolTransport: ToolCallTransport; /** Adapter for native_tools; null when grammar-only. */ @@ -315,6 +345,16 @@ export interface StepContext { * continuation) — contextual facts stay suppressed. */ userMessage?: string | null; + /** + * The operator's request behind this turn (`RunTurnOptions.originalRequest`), + * pinned into the prompt as `### request` once the packer has dropped + * the turn that carried it. See `request-section.ts`. + */ + originalRequest?: string; + /** The turn's reasoning effort — see `LlmStreamParams.reasoningEffort`. */ + reasoningEffort?: ReasoningEffort; + /** The turn's output ceiling — see `LlmStreamParams.maxOutputTokens`. */ + maxOutputTokens?: number; /** * Only the terminal `reply`/`finish` tools may run this step (the * loop's reserved final step). The prompt's tool catalog is left as it @@ -510,6 +550,9 @@ async function executeStepInner( ...(deps.contextWindow !== undefined ? { contextWindow: deps.contextWindow } : {}), + ...(deps.liveWorkerSlots !== undefined + ? { liveWorkerSlots: deps.liveWorkerSlots() } + : {}), ...(ctx.transientNotice !== undefined ? { transientNotice: ctx.transientNotice } : {}), @@ -517,6 +560,9 @@ async function executeStepInner( ? { profileFacts: ctx.profileFacts } : {}), ...(ctx.userMessage !== undefined ? { userMessage: ctx.userMessage } : {}), + ...(ctx.originalRequest !== undefined + ? { originalRequest: ctx.originalRequest } + : {}), }; const prompt = buildPrompt(promptInput); // A grammar (llama-server) fallback link behind a native-tools primary @@ -589,7 +635,10 @@ async function executeStepInner( // The cap every completion of this step runs under. Named here so the // failure detector can say which wall a cut-off reply hit. - const replyCap = ctx.maxTokens ?? getConfig().localModels.completionMaxTokens; + const replyCap = + ctx.maxTokens ?? + ctx.maxOutputTokens ?? + getConfig().localModels.completionMaxTokens; // The grammar for THIS request. Narrowed below the base grammar only // when the step has fewer tools than the catalog (the final step, an // orchestrator turn, a filtered worker); otherwise the base grammar @@ -609,6 +658,15 @@ async function executeStepInner( }), ...(grammarPrompt ? { grammarPrompt } : {}), ...(ctx.maxTokens !== undefined ? { maxTokens: ctx.maxTokens } : {}), + // The turn's own settings ride on every completion of the step; the + // repair retry spreads `llmParams`, so they inherit without a second + // wiring point. + ...(ctx.maxOutputTokens !== undefined + ? { maxOutputTokens: ctx.maxOutputTokens } + : {}), + ...(ctx.reasoningEffort !== undefined + ? { reasoningEffort: ctx.reasoningEffort } + : {}), }; const firstAttempt = await runInitialCompletion({ @@ -826,7 +884,7 @@ async function executeStepInner( if (batch.calls.length > getConfig().agent.maxParallelToolCalls) { return null; } - const trim = trimBatchToFirstApprovalGated(batch); + const trim = trimBatchToFirstApprovalGated(batch, turnPolicyForTrim(deps)); if (trim === null) return null; trimmedBatchNotice = formatBatchTrimNotice(trim); deps.onEvent?.({ @@ -835,20 +893,24 @@ async function executeStepInner( originalSize: trim.originalSize, kept: trim.kept.tool, dropped: trim.dropped.map((call) => call.tool), + ...(trim.refused.length > 0 + ? { refused: trim.refused.map(({ call }) => call.tool) } + : {}), reason: "approval-gated-batched", }); deps.metrics?.recordBatchTrimmed({ sessionId: ctx.session.id, reason: "approval-gated-batched", originalSize: trim.originalSize, - droppedCount: trim.dropped.length, + droppedCount: trim.dropped.length + trim.refused.length, }); - deps.logger?.info("batch trimmed to first approval-gated call", { + deps.logger?.info("batch trimmed to the first approval-gated call that can run", { sessionId: ctx.session.id, stepIndex: ctx.stepIndex, originalSize: trim.originalSize, kept: trim.kept.tool, dropped: trim.dropped.map((call) => call.tool), + refused: trim.refused.map(({ call, reason }) => `${call.tool}: ${reason}`), }); return { ok: true, @@ -1229,6 +1291,45 @@ async function executeStepInner( streamAborted: completion.earlyStop?.reason === "fabricated_transcript", }); } + // A `reply` that claims a check ran — "node --check", "tests pass", + // "verified" — with no matching call this turn is held back once, the + // same way an invented transcript is: the model gets a notice and one + // more step to run the check or drop the claim. The forced final step + // is exempt (it exists so a turn is never cut off without a summary), + // and the second time the claim is delivered and marked in the trace. + let unverified: CheckClaim[] = []; + let claimRefusal: string | null = null; + const tail = calls[calls.length - 1]; + if ( + deps.claimEvidence !== undefined && + suppressedTerminal === null && + tail !== undefined && + tail.tool === "reply" && + typeof tail.args?.text === "string" + ) { + unverified = unverifiedClaims(tail.args.text, [ + ...turnToolCalls(ctx.session.turns), + ...calls.slice(0, -1).map((call) => ({ tool: call.tool, args: call.args ?? {} })), + ]); + if (unverified.length > 0 && ctx.terminalOnly !== true) { + if (!deps.claimEvidence.noticed()) { + deps.claimEvidence.markNoticed(); + const notice = formatUnverifiedClaimNotice(unverified); + trimmedBatchNotice = + trimmedBatchNotice === undefined + ? notice + : `${trimmedBatchNotice}\n\n${notice}`; + claimRefusal = formatUnverifiedClaimRefusal(unverified); + suppressedTerminal = tail; + calls = calls.slice(0, -1); + deps.logger?.warn("reply claims a check that did not run; held once", { + sessionId: ctx.session.id, + stepIndex: ctx.stepIndex, + claims: unverified.map((claim) => claim.text), + }); + } + } + } const batchSize = calls.length + (suppressedTerminal !== null ? 1 : 0); // Registry membership: surfaces as `ToolExecutionError` (category @@ -1273,7 +1374,20 @@ async function executeStepInner( const suppressed = suppressedTerminal !== null && fabricated !== null ? suppressedTerminalRecord(suppressedTerminal, fabricated) - : null; + : suppressedTerminal !== null && claimRefusal !== null + ? { + call: suppressedTerminal, + result: compressToolResult({ + tool: suppressedTerminal.tool, + status: "error", + output: claimRefusal, + details: { + notDelivered: true, + unverifiedClaims: unverified.map((claim) => claim.text), + }, + }), + } + : null; if (suppressed !== null) { deps.onEvent?.({ type: "tool_call_parsed", @@ -1350,6 +1464,22 @@ async function executeStepInner( }); }, ); + // A reply delivered with claims nothing backs (the turn was already + // told once, or this is the forced final step) is marked, so the trace + // and the transcript say the check was never seen to run. + if (unverified.length > 0 && suppressed === null) { + const last = toolResults.length - 1; + const reply = toolResults[last]; + if (reply !== undefined && reply.tool === "reply") { + toolResults[last] = { + ...reply, + details: { + ...reply.details, + unverifiedClaims: unverified.map((claim) => claim.text), + }, + }; + } + } // The transcript cut this step's prompt was built on travels with the // session so the next step holds it (`packConversation`). @@ -2211,22 +2341,119 @@ export function isApprovalGatedOnlyFailure( */ export interface BatchTrimResult { kept: ToolCallPayload; + /** Calls dropped for the model to retry, in batch-index order. */ dropped: ToolCallPayload[]; + /** + * Calls dropped because the turn's policy (plan mode, the fusion + * orchestrator gate) would have refused them anyway, each with the + * gate that would have refused it. Not to be retried: re-emitting + * them earns the same refusal. + */ + refused: Array<{ call: ToolCallPayload; reason: string }>; /** Original batch size before trimming. Always >= 2. */ originalSize: number; } +/** + * The turn policy the trim consults before it picks a survivor. + * + * `refusedBy` runs the same predicates the batch executor's gates run + * at dispatch (`wouldRefuse` in `plan-mode.ts` / + * `fusion-orchestrator-mode.ts`) and names the gate, so the trim and + * the gate cannot disagree about a call. `preferTool` names the call + * that wins over emit order when it is present — on an orchestrator + * turn, `fusion.delegate`: the fan-out is what the turn exists to do, + * and a `mkdir` emitted ahead of it must not be the one that survives + * only to be refused (run 14: nine minutes of generation redone). + */ +export interface BatchTrimPolicy { + /** The gate that would refuse `tool`, or `null` when it may run. */ + refusedBy?: (tool: string) => string | null; + preferTool?: string; +} + +/** The fan-out tool an orchestrator turn prefers to keep. */ +const ORCHESTRATOR_PREFERRED_TOOL = "fusion.delegate"; + +export const TRIM_REFUSED_BY_PLAN_MODE = "refused by plan mode"; +export const TRIM_REFUSED_BY_FUSION_GATE = "refused by the fusion gate"; + +/** + * Build the trim policy from the step's dependencies — the same + * getters the batch context carries (`isPlanMode`, `isFusionOrchestrator` + * and the registry), read at trim time so a mode flipped mid-turn is + * honoured the way the gates honour it. Plan mode is named first when + * both would refuse, in the order the gates run. + */ +export function turnPolicyForTrim( + deps: Pick, +): BatchTrimPolicy { + const planMode = deps.isPlanMode?.() ?? false; + const orchestrator = deps.isFusionOrchestrator?.() ?? false; + if (!planMode && !orchestrator) return {}; + const ctx = { registry: deps.registry }; + return { + refusedBy: (tool) => + planMode && planModeWouldRefuse(tool, ctx) + ? TRIM_REFUSED_BY_PLAN_MODE + : orchestrator && fusionGateWouldRefuse(tool, ctx) + ? TRIM_REFUSED_BY_FUSION_GATE + : null, + ...(orchestrator ? { preferTool: ORCHESTRATOR_PREFERRED_TOOL } : {}), + }; +} + +/** + * Pick the survivor. Calls the turn policy would refuse are set aside + * first, so the kept call is one that can actually run; among the rest, + * `policy.preferTool` wins when present, else the first approval-gated + * call in emit order (writes typically precede the edits that depend on + * them). When every approval-gated call would be refused, the first one + * is kept anyway: it earns the gate's own refusal, which is the text + * that tells the model what to do instead. + */ export function trimBatchToFirstApprovalGated( batch: ToolCallBatch, + policy: BatchTrimPolicy = {}, ): BatchTrimResult | null { const calls = batch.calls; - const firstApprovalIdx = calls.findIndex( - (call) => resourceClassFor(call.tool) === "approval_gated", - ); - if (firstApprovalIdx === -1) return null; - const kept = calls[firstApprovalIdx]!; - const dropped = calls.filter((_, idx) => idx !== firstApprovalIdx); - return { kept, dropped, originalSize: calls.length }; + const isGated = (call: ToolCallPayload): boolean => + resourceClassFor(call.tool) === "approval_gated"; + if (!calls.some(isGated)) return null; + const refusedIdx = new Map(); + if (policy.refusedBy) { + calls.forEach((call, idx) => { + const reason = policy.refusedBy!(call.tool); + if (reason !== null) refusedIdx.set(idx, reason); + }); + } + const runnable = (idx: number): boolean => !refusedIdx.has(idx); + let keptIdx = -1; + if (policy.preferTool !== undefined) { + keptIdx = calls.findIndex( + (call, idx) => call.tool === policy.preferTool && runnable(idx), + ); + } + if (keptIdx === -1) { + keptIdx = calls.findIndex((call, idx) => isGated(call) && runnable(idx)); + } + if (keptIdx === -1) { + // Every gated call is refused: keep the first and let the gate + // speak — its refusal is the instruction, and the notice names the + // rest as refused so the model does not retry them one by one. + keptIdx = calls.findIndex(isGated); + refusedIdx.delete(keptIdx); + } + const kept = calls[keptIdx]!; + const dropped: ToolCallPayload[] = []; + const refused: BatchTrimResult["refused"] = []; + calls.forEach((call, idx) => { + if (idx === keptIdx) return; + const reason = refusedIdx.get(idx); + if (reason === undefined) dropped.push(call); + else refused.push({ call, reason }); + }); + return { kept, dropped, refused, originalSize: calls.length }; } /** @@ -2240,13 +2467,31 @@ export function trimBatchToFirstApprovalGated( * stable prefix). */ export function formatBatchTrimNotice(trim: BatchTrimResult): string { - const droppedNames = trim.dropped - .map((call) => `\`${call.tool}\``) - .join(", "); - return [ - `Your previous emission contained ${trim.originalSize} calls including approval-gated tools that must be solo (length-1 array). The runtime auto-executed \`${trim.kept.tool}\` and dropped the rest: ${droppedNames}.`, - "Retry the dropped calls now, one per step, each as a length-1 array. Do not re-batch them.", - ].join(" "); + const names = (calls: readonly ToolCallPayload[]): string => + calls.map((call) => `\`${call.tool}\``).join(", "); + const parts = [ + `Your previous emission contained ${trim.originalSize} calls including approval-gated tools that must be solo (length-1 array). The runtime auto-executed \`${trim.kept.tool}\`.`, + ]; + if (trim.dropped.length > 0) { + parts.push( + `Dropped from the batch — retry: ${names(trim.dropped)}. Retry them now, one per step, each as a length-1 array. Do not re-batch them.`, + ); + } + if (trim.refused.length > 0) { + // Grouped by gate, so the model reads the same rule the gate's own + // refusal states — and does not retry a call that earns it again. + const byReason = new Map(); + for (const { call, reason } of trim.refused) { + byReason.set(reason, [...(byReason.get(reason) ?? []), call]); + } + const groups = [...byReason] + .map(([reason, calls]) => `${names(calls)} (${reason})`) + .join("; "); + parts.push( + `Dropped because this turn's policy would refuse them — do not retry: ${groups}.`, + ); + } + return parts.join(" "); } /** diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index d1e9a2e9..4c74cbd4 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -2203,6 +2203,13 @@ export interface UserConfigFile { // (default `true`). All additive: an older file parses with every field // absent, which is the native layout, no extra parameters, `auto` // reasoning and the cache-capable routes for Google models. +// v66: three additive `llm.runMode.fusion` fields — `cloudWorkers` +// (1..32, default 4: the fan-out cap when the worker leg has no slot +// pool), `workerReasoning` (low|medium|high, unset by default: the +// reasoning effort sent with every worker completion) and +// `workerMaxOutputTokens` (unset by default: the per-step output cap for +// worker completions). An older file parses with all three absent and +// behaves as before, except that a cloud fan-out is now bounded at 4. export const USER_CONFIG_VERSION = 66; /** diff --git a/src/config/llm-run-mode-config.test.ts b/src/config/llm-run-mode-config.test.ts index 1478d17d..33419807 100644 --- a/src/config/llm-run-mode-config.test.ts +++ b/src/config/llm-run-mode-config.test.ts @@ -51,6 +51,7 @@ describe("parseLlmRunModeConfig", () => { workerProvider: "local-llama", workerModel: "qwen-3.5-4b", workers: 4, + cloudWorkers: 6, workerMaxSteps: 25, workerTimeoutMs: 120_000, }; @@ -200,3 +201,57 @@ describe("scrubRunModeProviderPins", () => { }); }); }); + +describe("cloudWorkers (F21)", () => { + const providers = [ + { id: "openrouter", kind: "openrouter" }, + { id: "local-llama", kind: "llama-server" }, + ]; + it("is optional and bounded 1..32", () => { + expect( + parseLlmRunModeConfig({ fusion: { cloudWorkers: 8 } }, providers, "llm.runMode") + .fusion?.cloudWorkers, + ).toBe(8); + expect( + parseLlmRunModeConfig({ fusion: {} }, providers, "llm.runMode").fusion + ?.cloudWorkers, + ).toBeUndefined(); + for (const bad of [0, 33, 2.5, "4"]) { + expect(() => + parseLlmRunModeConfig( + { fusion: { cloudWorkers: bad } }, + providers, + "llm.runMode", + ), + ).toThrow(/llm\.runMode\.fusion\.cloudWorkers/); + } + }); +}); + +describe("workerReasoning / workerMaxOutputTokens (F20)", () => { + const providers = [ + { id: "openrouter", kind: "openrouter" }, + { id: "local-llama", kind: "llama-server" }, + ]; + it("are optional, and validated when present", () => { + const parsed = parseLlmRunModeConfig( + { fusion: { workerReasoning: "low", workerMaxOutputTokens: 12_000 } }, + providers, + "llm.runMode", + ); + expect(parsed.fusion).toEqual({ workerReasoning: "low", workerMaxOutputTokens: 12_000 }); + expect(parseLlmRunModeConfig({ fusion: {} }, providers, "llm.runMode").fusion).toEqual({}); + expect(() => + parseLlmRunModeConfig({ fusion: { workerReasoning: "max" } }, providers, "llm.runMode"), + ).toThrow(/llm\.runMode\.fusion\.workerReasoning/); + for (const bad of [0, -1, 2.5, "8192", 1_000_001]) { + expect(() => + parseLlmRunModeConfig( + { fusion: { workerMaxOutputTokens: bad } }, + providers, + "llm.runMode", + ), + ).toThrow(/llm\.runMode\.fusion\.workerMaxOutputTokens/); + } + }); +}); diff --git a/src/config/llm-run-mode-config.ts b/src/config/llm-run-mode-config.ts index cfb81029..f51f3b1c 100644 --- a/src/config/llm-run-mode-config.ts +++ b/src/config/llm-run-mode-config.ts @@ -15,6 +15,16 @@ import { ConfigValidationError } from "./config-validation-error.js"; */ export type RunModeName = "local" | "cloud" | "fusion"; +/** `llm.runMode.fusion.workerReasoning` — same levels as `ReasoningEffort`. */ +export type FusionWorkerReasoning = "low" | "medium" | "high"; +export const FUSION_WORKER_REASONING_LEVELS: readonly FusionWorkerReasoning[] = [ + "low", + "medium", + "high", +]; +/** Upper bound on `workerMaxOutputTokens`; anything larger is a typo. */ +export const FUSION_WORKER_MAX_OUTPUT_TOKENS_MAX = 1_000_000; + export type UserLlmFusionConfig = { /** * The orchestrator leg. Must name a configured provider whose kind is @@ -53,6 +63,27 @@ export type UserLlmFusionConfig = { * slot affinity. */ workers?: number; + /** + * Ceiling on the fan-out width when the workers run on a cloud leg, + * 1..32. Default 4. A local leg is bounded by its request slots; a + * cloud leg has no such limit, only a bill, so an over-ambitious + * `maxWorkers` is clamped here and the result says so. + */ + cloudWorkers?: number; + /** + * Reasoning effort sent with every worker completion, mapped per + * provider family (OpenRouter `reasoning.effort`, OpenAI-compatible + * `reasoning_effort`). Unset by default: the provider's own default. + * A run spent 87 % of its worker output on hidden reasoning and 41K + * tokens deciding to read a file; this is the knob for that. + */ + workerReasoning?: FusionWorkerReasoning; + /** + * Per-step output cap for worker completions, in tokens. Unset by + * default (the model's maximum). Keep it above the largest single + * write a worker makes (~10K tokens) or it re-creates the 8192 wall. + */ + workerMaxOutputTokens?: number; /** Step ceiling per worker turn. Default 40. */ workerMaxSteps?: number; /** Wall-clock ceiling per worker turn, in ms. Default 600 000. */ @@ -76,6 +107,13 @@ export const LOCAL_PROVIDER_KIND = "llama-server"; export const FUSION_WORKERS_MIN = 1; export const FUSION_WORKERS_MAX = 8; export const DEFAULT_FUSION_WORKERS = 2; +export const FUSION_CLOUD_WORKERS_MAX = 32; +/** + * Cloud fan-out cap. Four, not `workers`' two: a seven-task cloud fan-out + * took three minutes at full width and would take ten at two, and the + * cap exists to stop a runaway forty, not to slow an ordinary fan-out. + */ +export const DEFAULT_FUSION_CLOUD_WORKERS = 4; export const DEFAULT_FUSION_WORKER_MAX_STEPS = 40; /** * How long one worker may take before its leg is cancelled. @@ -196,6 +234,35 @@ function parseFusion( FUSION_WORKERS_MAX, ); } + if (obj.cloudWorkers !== undefined) { + out.cloudWorkers = parseBoundedInt( + obj.cloudWorkers, + `${field}.cloudWorkers`, + FUSION_WORKERS_MIN, + FUSION_CLOUD_WORKERS_MAX, + ); + } + if (obj.workerReasoning !== undefined) { + const level = obj.workerReasoning; + if ( + typeof level !== "string" || + !FUSION_WORKER_REASONING_LEVELS.includes(level as FusionWorkerReasoning) + ) { + throw new ConfigValidationError( + `${field}.workerReasoning`, + `expected ${FUSION_WORKER_REASONING_LEVELS.join("|")}`, + ); + } + out.workerReasoning = level as FusionWorkerReasoning; + } + if (obj.workerMaxOutputTokens !== undefined) { + out.workerMaxOutputTokens = parseBoundedInt( + obj.workerMaxOutputTokens, + `${field}.workerMaxOutputTokens`, + 1, + FUSION_WORKER_MAX_OUTPUT_TOKENS_MAX, + ); + } if (obj.workerMaxSteps !== undefined) { out.workerMaxSteps = parseBoundedInt( obj.workerMaxSteps, diff --git a/src/llm/llama-server-client.test.ts b/src/llm/llama-server-client.test.ts index 09960424..0dd4e881 100644 --- a/src/llm/llama-server-client.test.ts +++ b/src/llm/llama-server-client.test.ts @@ -14,6 +14,112 @@ function createMockFetch(handler: Handler): typeof fetch { }) as typeof fetch; } +describe("LlamaServerClient n_predict from the turn's ceiling (F20)", () => { + function clientCapturing(bodies: Array>): LlamaServerClient { + return new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch(async (_url, init) => { + bodies.push(JSON.parse(String(init.body)) as Record); + return new Response(JSON.stringify({ content: "x", stop: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + }); + } + + it("caps n_predict from maxOutputTokens, under the per-step maxTokens", async () => { + const bodies: Array> = []; + const client = clientCapturing(bodies); + await client.complete({ prompt: "p", maxOutputTokens: 12_000 }); + await client.complete({ prompt: "p", maxOutputTokens: 12_000, maxTokens: 32_000 }); + expect(bodies[0]?.n_predict).toBe(12_000); + expect(bodies[1]?.n_predict).toBe(32_000); + // A reasoning effort means nothing to llama-server and is not sent. + await client.complete({ prompt: "p", reasoningEffort: "low" }); + expect(JSON.stringify(bodies[2])).not.toContain("reasoning"); + }); +}); + +describe("LlamaServerClient.measuredTokensPerSecond (F19)", () => { + function clientWith(replies: Array>): LlamaServerClient { + let i = 0; + return new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch(async () => { + const reply = replies[Math.min(i, replies.length - 1)]!; + i += 1; + return new Response(JSON.stringify({ content: "x", stop: true, ...reply }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + }); + } + + it("is null until a completion reports timings, then a rolling mean", async () => { + const client = clientWith([ + { timings: { predicted_per_second: 30 } }, + // An older server: no rate, but a count and a duration. + { timings: { predicted_n: 100, predicted_ms: 10_000 } }, + // Nothing usable: ignored, the mean stands. + { timings: { predicted_n: 0, predicted_ms: 0 } }, + {}, + ]); + expect(client.measuredTokensPerSecond()).toBeNull(); + await client.complete({ prompt: "p" }); + expect(client.measuredTokensPerSecond()).toBe(30); + await client.complete({ prompt: "p" }); + expect(client.measuredTokensPerSecond()).toBe(20); + await client.complete({ prompt: "p" }); + await client.complete({ prompt: "p" }); + expect(client.measuredTokensPerSecond()).toBe(20); + }); + + it("keeps only the last eight completions, so it follows the load", async () => { + const client = clientWith([{ timings: { predicted_per_second: 100 } }]); + for (let i = 0; i < 3; i += 1) await client.complete({ prompt: "p" }); + const slow = clientWith([{ timings: { predicted_per_second: 100 } }]); + void slow; + // Eight slow completions push the fast ones out of the window. + let n = 0; + const mixed = new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch(async () => { + n += 1; + return new Response( + JSON.stringify({ + content: "x", + stop: true, + timings: { predicted_per_second: n <= 2 ? 100 : 10 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + }); + for (let i = 0; i < 10; i += 1) await mixed.complete({ prompt: "p" }); + expect(mixed.measuredTokensPerSecond()).toBe(10); + }); + + it("reads the stream's final event too", async () => { + const client = new LlamaServerClient({ + baseUrl: "http://127.0.0.1:9999", + fetchImpl: createMockFetch( + async () => + new Response( + 'data: {"content":"ok","stop":false}\n\n' + + 'data: {"content":"","stop":true,"timings":{"predicted_per_second":42}}\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + ), + }); + const iterator = client.completeStream({ prompt: "hi" }); + let next = await iterator.next(); + while (!next.done) next = await iterator.next(); + expect(client.measuredTokensPerSecond()).toBe(42); + }); +}); + describe("LlamaServerClient.complete", () => { it("posts JSON to /completion with grammar and slot_id", async () => { let captured: { url: string; body: unknown } | null = null; diff --git a/src/llm/llama-server-client.ts b/src/llm/llama-server-client.ts index 7865b4fc..b300917a 100644 --- a/src/llm/llama-server-client.ts +++ b/src/llm/llama-server-client.ts @@ -210,6 +210,9 @@ export interface LlamaServerProps { * `complete()` and a streaming `completeStream()` — both hand a GBNF grammar * and a reusable slot_id to llama.cpp for KV-cache reuse. */ +/** Completions the rolling throughput mean is taken over. */ +const THROUGHPUT_WINDOW = 8; + export class LlamaServerClient { /** When set, this fixed base wins; otherwise each request reads `getConfig().llama.url`. */ private readonly baseUrlOverride: string | undefined; @@ -221,6 +224,8 @@ export class LlamaServerClient { private readonly completionRetriesOverride: number | undefined; private readonly completionRetryBackoffMsOverride: number | undefined; private readonly sleep: (ms: number) => Promise; + /** Recent generation speeds, tokens/s, newest last — see `measuredTokensPerSecond`. */ + private readonly throughputSamples: number[] = []; constructor(options: LlamaServerClientOptions = {}) { const config = getConfig(); @@ -249,6 +254,42 @@ export class LlamaServerClient { this.sleep = options.sleep ?? defaultSleep; } + /** + * The server's measured generation speed, tokens per second, as a + * rolling mean of the last `THROUGHPUT_WINDOW` completions; `null` + * before any completion reported its timings. Fusion sizes a local + * worker's time limit from it (`estimateWorkerTimeoutMs`): a limit + * from a measured speed, not a guess, and one that follows the load + * on the machine rather than a constant. + */ + measuredTokensPerSecond(): number | null { + if (this.throughputSamples.length === 0) return null; + const sum = this.throughputSamples.reduce((a, b) => a + b, 0); + return sum / this.throughputSamples.length; + } + + /** + * Fold one completion's `timings` in. llama.cpp states the speed + * directly (`predicted_per_second`); an older server without it + * still reports the count and the milliseconds it took. + */ + private recordThroughput(payload: Record): void { + const timings = payload.timings; + if (timings === null || typeof timings !== "object") return; + const t = timings as Record; + let perSecond = toNumber(t.predicted_per_second, Number.NaN); + if (!Number.isFinite(perSecond) || perSecond <= 0) { + const n = toNumber(t.predicted_n, 0); + const ms = toNumber(t.predicted_ms, 0); + perSecond = n > 0 && ms > 0 ? (n / ms) * 1000 : Number.NaN; + } + if (!Number.isFinite(perSecond) || perSecond <= 0) return; + this.throughputSamples.push(perSecond); + if (this.throughputSamples.length > THROUGHPUT_WINDOW) { + this.throughputSamples.shift(); + } + } + async fetchProps(): Promise { const config = getConfig(); const base = this.baseUrlOverride ?? config.localModels.url; @@ -302,6 +343,7 @@ export class LlamaServerClient { throw await buildHttpError(response, url); } const json = (await response.json()) as Record; + this.recordThroughput(json); return normaliseCompletionResponse(json); } catch (err) { throw this.wrapTransportError(err, url, timedOut()); @@ -450,6 +492,7 @@ export class LlamaServerClient { yield { delta, reasoningDelta, done: false }; } if (parsed.stop) { + this.recordThroughput(parsed); finalResult = normaliseCompletionResponse(parsed); if (finalResult.content.length === 0) { finalResult.content = accumulated; @@ -681,8 +724,10 @@ export class LlamaServerClient { temperature: request.temperature ?? ENV_TEMPERATURE ?? 0.2, top_p: request.topP ?? ENV_TOP_P ?? 0.95, top_k: request.topK ?? ENV_TOP_K ?? 40, + // The per-step cap first, then the turn's own ceiling (a fusion + // worker's `workerMaxOutputTokens`), then the config knob. n_predict: resolveNPredict( - request.maxTokens, + request.maxTokens ?? request.maxOutputTokens, config.localModels.completionMaxTokens, ), repeat_penalty: request.repeatPenalty ?? 1.1, diff --git a/src/llm/provider/completion-types.ts b/src/llm/provider/completion-types.ts index bfde9e88..64565b72 100644 --- a/src/llm/provider/completion-types.ts +++ b/src/llm/provider/completion-types.ts @@ -6,6 +6,14 @@ export type ToolCallTransport = "grammar" | "native_tools"; +/** + * How hard a reasoning model may think for one completion. Mapped per + * provider family in the body builder (OpenRouter `reasoning.effort`, + * OpenAI-compatible `reasoning_effort`); providers without a mapping + * ignore it. + */ +export type ReasoningEffort = "low" | "medium" | "high"; + export interface CompletionUsage { promptTokens: number; completionTokens: number; @@ -74,6 +82,22 @@ export interface CompletionRequest { topP?: number; topK?: number; maxTokens?: number; + /** + * Output ceiling for the turn this request belongs to (a fusion + * worker's `workerMaxOutputTokens`). A per-step `maxTokens` — the + * truncation retry's raised cap — wins over it; absent both, the + * provider's own ceiling applies. + */ + maxOutputTokens?: number; + /** + * How hard a reasoning model should think on this completion (the + * turn's `reasoningEffort`). Spelled per vendor by the body builder + * (`reasoning: { effort }` on OpenRouter, `reasoning_effort` on + * OpenAI-compatible services) and omitted for kinds that document + * neither. Ignored by grammar-only providers. Set by the fusion + * fan-out for its workers. + */ + reasoningEffort?: ReasoningEffort; seed?: number; repeatPenalty?: number; repeatLastN?: number; @@ -95,14 +119,6 @@ export interface CompletionRequest { * they rely on `grammar` instead. */ responseFormat?: ResponseFormatJsonSchema; - /** - * How hard a reasoning model should think on this completion. Spelled - * per vendor by the body builder (`reasoning: { effort }` on - * OpenRouter, `reasoning_effort` on OpenAI-compatible services) and - * omitted for kinds that document neither. Ignored by grammar-only - * providers. Set by the fusion fan-out for its workers. - */ - reasoningEffort?: "low" | "medium" | "high"; } /** diff --git a/src/llm/provider/openai/openai-build-body.test.ts b/src/llm/provider/openai/openai-build-body.test.ts index 9c684f71..669d1116 100644 --- a/src/llm/provider/openai/openai-build-body.test.ts +++ b/src/llm/provider/openai/openai-build-body.test.ts @@ -642,3 +642,38 @@ describe("buildOpenAiChatBody — the native message layout", () => { expect(assistant.tool_calls[0]?.function.name).toBe("OS.FS.READ"); }); }); + +describe("reasoning effort and the turn's output ceiling (F20)", () => { + it("maps reasoningEffort to the field each family reads, and omits it elsewhere", () => { + const request = { prompt: "hi", reasoningEffort: "low" as const }; + const openrouter = buildOpenAiChatBody(request, "m", false, undefined, undefined, undefined, undefined, { providerKind: "openrouter" }); + expect(openrouter.reasoning).toEqual({ effort: "low" }); + expect(openrouter).not.toHaveProperty("reasoning_effort"); + const compatible = buildOpenAiChatBody(request, "m", false, undefined, undefined, undefined, undefined, { providerKind: "openai-compatible" }); + expect(compatible.reasoning_effort).toBe("low"); + expect(compatible).not.toHaveProperty("reasoning"); + for (const kind of [undefined, "aimlapi", "gemini"]) { + const body = buildOpenAiChatBody(request, "m", false, undefined, undefined, undefined, undefined, { + ...(kind ? { providerKind: kind } : {}), + }); + expect(body).not.toHaveProperty("reasoning"); + expect(body).not.toHaveProperty("reasoning_effort"); + } + // No effort asked: nothing sent, whatever the family. + expect( + buildOpenAiChatBody({ prompt: "hi" }, "m", false, undefined, undefined, undefined, undefined, { providerKind: "openrouter" }), + ).not.toHaveProperty("reasoning"); + }); + + it("caps max_tokens from the turn's ceiling, under the per-step cap and over the provider's", () => { + expect(buildOpenAiChatBody({ prompt: "hi", maxOutputTokens: 12_000 }, "m", false).max_tokens).toBe(12_000); + expect( + buildOpenAiChatBody({ prompt: "hi", maxOutputTokens: 12_000, maxTokens: 32_000 }, "m", false).max_tokens, + ).toBe(32_000); + expect( + buildOpenAiChatBody({ prompt: "hi", maxOutputTokens: 12_000 }, "m", false, undefined, 4_000).max_tokens, + ).toBe(12_000); + expect(buildOpenAiChatBody({ prompt: "hi" }, "m", false, undefined, 4_000).max_tokens).toBe(4_000); + expect(buildOpenAiChatBody({ prompt: "hi" }, "m", false)).not.toHaveProperty("max_tokens"); + }); +}); diff --git a/src/llm/provider/openai/openai-build-body.ts b/src/llm/provider/openai/openai-build-body.ts index 0701fa41..5aab34b4 100644 --- a/src/llm/provider/openai/openai-build-body.ts +++ b/src/llm/provider/openai/openai-build-body.ts @@ -127,12 +127,17 @@ export function buildOpenAiChatBody( // the field, or a deployment that wants a hard ceiling, sets it // through the entry's `extraBody` — `max_tokens` is deliberately not // in `RESERVED_BODY_KEYS`, so that passthrough wins. - // Order: what this call asked for, else the provider's configured - // ceiling, else nothing at all. The field is the model family's own: - // OpenAI's reasoning models answer `max_tokens` with "'max_tokens' is - // not supported with this model. Use 'max_completion_tokens' instead." - const cap = filtered.maxTokens ?? maxOutputTokens; + // Order: what this call asked for, else the turn's own ceiling (a + // fusion worker's `workerMaxOutputTokens`), else the provider's + // configured ceiling, else nothing at all. The field is the model + // family's own: OpenAI's reasoning models answer `max_tokens` with + // "'max_tokens' is not supported with this model. Use + // 'max_completion_tokens' instead." + const cap = + filtered.maxTokens ?? filtered.maxOutputTokens ?? maxOutputTokens; if (typeof cap === "number") body[profile.capField] = cap; + // Reasoning effort, in the field this kind reads (`model-params.ts`); + // omitted for a kind without a known one rather than guessed. if (filtered.reasoningEffort !== undefined) { Object.assign( body, diff --git a/src/llm/provider/openrouter/openrouter-provider-routing.test.ts b/src/llm/provider/openrouter/openrouter-provider-routing.test.ts index 9f86028c..7e187546 100644 --- a/src/llm/provider/openrouter/openrouter-provider-routing.test.ts +++ b/src/llm/provider/openrouter/openrouter-provider-routing.test.ts @@ -195,3 +195,16 @@ describe("OpenRouterProvider — cache-capable routes for Google models", () => expect(other.bodies[0]).not.toHaveProperty("provider"); }); }); + +describe("OpenRouterProvider — reasoning effort (F20)", () => { + it("sends `reasoning: { effort }` on both paths, and only when asked", async () => { + const { bodies, fetchImpl } = capture(unaryReply); + const provider = openRouter(fetchImpl); + await provider.complete({ ...request, reasoningEffort: "low" }); + await drain(provider.completeStream({ ...request, reasoningEffort: "high" })); + await provider.complete(request); + expect(bodies[0]?.reasoning).toEqual({ effort: "low" }); + expect(bodies[1]?.reasoning).toEqual({ effort: "high" }); + expect(bodies[2]).not.toHaveProperty("reasoning"); + }); +}); diff --git a/src/llm/provider/openrouter/openrouter-provider.ts b/src/llm/provider/openrouter/openrouter-provider.ts index 526d7be3..64400d4c 100644 --- a/src/llm/provider/openrouter/openrouter-provider.ts +++ b/src/llm/provider/openrouter/openrouter-provider.ts @@ -61,6 +61,9 @@ export class OpenRouterProvider extends OpenAiProvider { const defaultChatModel = options.defaultChatModel ?? "openrouter/auto"; super({ ...options, + // This subclass is the OpenRouter kind by construction; the body + // builder spells `reasoningEffort` per kind (`model-params.ts`). + providerKind: "openrouter", id: options.id, // OpenAiProvider normalizes the base URL. baseUrl: options.baseUrl ?? DEFAULT_OPENROUTER_BASE, diff --git a/src/llm/run-mode/index.ts b/src/llm/run-mode/index.ts index 6cd3b190..c7e871bb 100644 --- a/src/llm/run-mode/index.ts +++ b/src/llm/run-mode/index.ts @@ -7,3 +7,4 @@ export type { } from "./resolve-run-mode.js"; export { describeRunModeDegradation } from "./run-mode-degradation.js"; export { describeRunMode, runModeLabel } from "./run-mode-summary.js"; +export type { RunModeWorkerFacts } from "./run-mode-summary.js"; diff --git a/src/llm/run-mode/resolve-run-mode.test.ts b/src/llm/run-mode/resolve-run-mode.test.ts index f90353bf..5fa59c04 100644 --- a/src/llm/run-mode/resolve-run-mode.test.ts +++ b/src/llm/run-mode/resolve-run-mode.test.ts @@ -189,10 +189,29 @@ describe("resolveRunMode", () => { expect(resolveRunMode(llm("openrouter", { mode: "fusion" }))).toMatchObject( { workers: 2, + cloudWorkers: 4, workerMaxSteps: 40, workerTimeoutMs: DEFAULT_FUSION_WORKER_TIMEOUT_MS, }, ); + expect( + resolveRunMode( + llm("openrouter", { mode: "fusion", fusion: { cloudWorkers: 9 } }), + ).cloudWorkers, + ).toBe(9); + // F20: the worker settings are present only when configured — an + // absent one must reach the provider as "its default", not as a value. + const plain = resolveRunMode(llm("openrouter", { mode: "fusion" })); + expect(plain).not.toHaveProperty("workerReasoning"); + expect(plain).not.toHaveProperty("workerMaxOutputTokens"); + expect( + resolveRunMode( + llm("openrouter", { + mode: "fusion", + fusion: { workerReasoning: "high", workerMaxOutputTokens: 20_000 }, + }), + ), + ).toMatchObject({ workerReasoning: "high", workerMaxOutputTokens: 20_000 }); expect( resolveRunMode( llm("openrouter", { diff --git a/src/llm/run-mode/resolve-run-mode.ts b/src/llm/run-mode/resolve-run-mode.ts index c006afef..12831f20 100644 --- a/src/llm/run-mode/resolve-run-mode.ts +++ b/src/llm/run-mode/resolve-run-mode.ts @@ -1,5 +1,9 @@ -import type { RunModeName } from "../../config/llm-run-mode-config.js"; +import type { + FusionWorkerReasoning, + RunModeName, +} from "../../config/llm-run-mode-config.js"; import { + DEFAULT_FUSION_CLOUD_WORKERS, DEFAULT_FUSION_WORKER_MAX_STEPS, DEFAULT_FUSION_WORKER_TIMEOUT_MS, DEFAULT_FUSION_WORKERS, @@ -37,6 +41,16 @@ export type ResolvedRunMode = { * `maxWorkers`. Not a ceiling — see `UserLlmFusionConfig.workers`. */ workers: number; + /** + * Cap on the fan-out width when the worker leg is a cloud provider — + * `llm.runMode.fusion.cloudWorkers`, default 4. Always set by the + * resolver; optional only so hand-built fixtures elsewhere stay valid. + */ + cloudWorkers?: number; + /** `llm.runMode.fusion.workerReasoning`, present only when configured. */ + workerReasoning?: FusionWorkerReasoning; + /** `llm.runMode.fusion.workerMaxOutputTokens`, present only when configured. */ + workerMaxOutputTokens?: number; workerMaxSteps: number; workerTimeoutMs: number; /** @@ -162,6 +176,13 @@ export function resolveRunMode( ? (opts.managedModelId ?? worker.model ?? null) : (worker?.defaultChatModel ?? worker?.model ?? null)), workers: fusion?.workers ?? DEFAULT_FUSION_WORKERS, + cloudWorkers: fusion?.cloudWorkers ?? DEFAULT_FUSION_CLOUD_WORKERS, + ...(fusion?.workerReasoning === undefined + ? {} + : { workerReasoning: fusion.workerReasoning }), + ...(fusion?.workerMaxOutputTokens === undefined + ? {} + : { workerMaxOutputTokens: fusion.workerMaxOutputTokens }), workerMaxSteps: fusion?.workerMaxSteps ?? DEFAULT_FUSION_WORKER_MAX_STEPS, workerTimeoutMs: fusion?.workerTimeoutMs ?? DEFAULT_FUSION_WORKER_TIMEOUT_MS, diff --git a/src/llm/run-mode/run-mode-summary.test.ts b/src/llm/run-mode/run-mode-summary.test.ts index d3f8b152..392a235a 100644 --- a/src/llm/run-mode/run-mode-summary.test.ts +++ b/src/llm/run-mode/run-mode-summary.test.ts @@ -62,6 +62,34 @@ describe("describeRunMode", () => { expect(line).not.toContain("stored fusion, effective"); }); + it("says what will run when given the worker facts (F21)", () => { + // `workers` is only the default for a call that names no width; it + // read "2 workers" next to a 5-slot server. With the facts the line + // states the bound that actually applies. + expect( + describeRunMode(base, { workerLeg: "local", workerSlots: 5 }), + ).toContain("; workers: up to 5 local slots"); + expect( + describeRunMode(base, { workerLeg: "local", workerSlots: null }), + ).toContain("; workers: local slots not observed yet"); + expect( + describeRunMode({ ...base, cloudWorkers: 6 }, { workerLeg: "cloud", workerSlots: null }), + ).toContain("; workers: up to 6 cloud workers"); + expect( + describeRunMode(base, { workerLeg: "cloud", workerSlots: null }), + ).toContain("; workers: up to 4 cloud workers"); + expect( + describeRunMode(base, { workerLeg: null, workerSlots: null }), + ).not.toContain("workers:"); + // A plain mode has no workers to describe. + expect( + describeRunMode( + { ...base, stored: null, effective: "local", primaryProviderId: "local-llama" }, + { workerLeg: "local", workerSlots: 5 }, + ), + ).toBe("Local — active provider local-llama"); + }); + it("capitalises the mode words", () => { expect(["local", "cloud", "fusion"].map(runModeLabel)).toEqual([ "Local", diff --git a/src/llm/run-mode/run-mode-summary.ts b/src/llm/run-mode/run-mode-summary.ts index 2ebee9f0..772efc8b 100644 --- a/src/llm/run-mode/run-mode-summary.ts +++ b/src/llm/run-mode/run-mode-summary.ts @@ -1,3 +1,4 @@ +import { DEFAULT_FUSION_CLOUD_WORKERS } from "../../config/llm-run-mode-config.js"; import { describeRunModeDegradation } from "./run-mode-degradation.js"; import type { ResolvedRunMode } from "./resolve-run-mode.js"; @@ -6,21 +7,56 @@ export function runModeLabel(mode: ResolvedRunMode["effective"]): string { return mode === "fusion" ? "Fusion" : mode === "cloud" ? "Cloud" : "Local"; } +/** + * What actually bounds a fan-out, for the status line: the worker leg + * and — for a local one — its request slots when known. The same + * facts `resolveFusionMachineFacts` states to the model. + */ +export interface RunModeWorkerFacts { + workerLeg: "local" | "cloud" | null; + workerSlots: number | null; +} + +/** + * "workers: up to N local slots" / "up to N cloud workers" — what will + * run, as opposed to `workers`, which is only the default for a call + * that names no width (and read "2 workers" next to a 5-slot server). + */ +function describeWorkerCapacity( + rm: ResolvedRunMode, + facts: RunModeWorkerFacts, +): string | null { + if (facts.workerLeg === "cloud") { + const cap = rm.cloudWorkers ?? DEFAULT_FUSION_CLOUD_WORKERS; + return `workers: up to ${cap} cloud worker${cap === 1 ? "" : "s"}`; + } + if (facts.workerLeg === "local") { + return facts.workerSlots === null + ? "workers: local slots not observed yet" + : `workers: up to ${facts.workerSlots} local slot${facts.workerSlots === 1 ? "" : "s"}`; + } + return null; +} + /** * One operator-facing line describing what the run mode resolves to — * the body of `/runmode status`. Says when the stored and the effective * mode disagree, because that is the one state a reader cannot infer - * from the chip alone. + * from the chip alone. With `facts`, also says what will run. */ -export function describeRunMode(rm: ResolvedRunMode): string { +export function describeRunMode( + rm: ResolvedRunMode, + facts?: RunModeWorkerFacts, +): string { const parts: string[] = []; if (rm.effective === "fusion") { + const capacity = facts === undefined ? null : describeWorkerCapacity(rm, facts); parts.push( `Fusion — orchestrator ${rm.orchestratorProviderId}${ rm.orchestratorModel ? ` (${rm.orchestratorModel})` : "" }, ${rm.workers} worker${rm.workers === 1 ? "" : "s"} on ${rm.workerProviderId}${ rm.workerModel ? ` (${rm.workerModel})` : "" - }`, + }${capacity === null ? "" : `; ${capacity}`}`, ); } else { parts.push( diff --git a/src/llm/slot-manager.test.ts b/src/llm/slot-manager.test.ts index 8cf27031..cae89cc8 100644 --- a/src/llm/slot-manager.test.ts +++ b/src/llm/slot-manager.test.ts @@ -15,6 +15,15 @@ describe("SlotManager", () => { } }); + it("reports the pool as observed only after a /props answer sized it (F21)", () => { + const mgr = new SlotManager(2); + expect(mgr.observedPoolSize()).toBeNull(); + mgr.resize(2); + expect(mgr.observedPoolSize()).toBe(2); + mgr.resize(5); + expect(mgr.observedPoolSize()).toBe(mgr.poolSize()); + }); + describe("resize", () => { it("widens the pool to the discovered slot count", () => { const mgr = new SlotManager(); diff --git a/src/llm/slot-manager.ts b/src/llm/slot-manager.ts index 116e77f6..2eafbcd0 100644 --- a/src/llm/slot-manager.ts +++ b/src/llm/slot-manager.ts @@ -52,6 +52,8 @@ export class SlotManager { private slotPool: number[]; private nextRoundRobin = 0; private reservedReflectionSlot: number | null = null; + /** Whether a `/props` answer has ever sized the pool — see `observedPoolSize`. */ + private observed = false; constructor(slotCount = DEFAULT_SLOT_COUNT) { if (slotCount <= 0) { @@ -77,6 +79,18 @@ export class SlotManager { return this.slotPool.length; } + /** + * `poolSize()` once the server has been asked, `null` before. The + * constructor's count is a guess (one slot, or the configured + * `--parallel`); `resize()` is called from a `/props` answer, and only + * a number the server itself reported is one the `### fusion` machine + * facts may state — a guessed slot count is a number the model plans + * against. + */ + observedPoolSize(): number | null { + return this.observed ? this.poolSize() : null; + } + /** * Re-size the pool to the server's actual slot count, discovered from a * later `/props` probe. No-op when the count is unchanged, so the common @@ -97,6 +111,9 @@ export class SlotManager { if (slotCount <= 0) { throw new Error("slotCount must be positive"); } + // An unchanged count is still an observation: the server confirmed + // the number the pool was built with. + this.observed = true; if (slotCount === this.slotCount) return; const reserved = this.reservedReflectionSlot !== null && diff --git a/src/prompt/build-prompt-types.ts b/src/prompt/build-prompt-types.ts index 4bcea07a..1287a628 100644 --- a/src/prompt/build-prompt-types.ts +++ b/src/prompt/build-prompt-types.ts @@ -66,6 +66,20 @@ export interface BuildPromptInput { * UI can still tell a probed window from a catalogued one. */ contextWindow?: number | null; + /** + * The local worker leg's request-slot count as the llama-server + * reported it, `null` (or absent) until observed. The `### fusion` + * machine facts state it for an external server, whose `--parallel` + * the config cannot know. See `resolveFusionMachineFacts`. + */ + liveWorkerSlots?: number | null; + /** + * The operator's request behind the running turn, as the runtime + * recorded it (`pickOriginalRequest`). Rendered as `### request` + * before `### conversation` only when the packer has dropped the user + * turn that carried it — see `request-section.ts`. + */ + originalRequest?: string; worldSnapshotMaxTokens?: number; completionMaxTokens?: number; transientNotice?: string; diff --git a/src/prompt/build-prompt.test.ts b/src/prompt/build-prompt.test.ts index 79ef2820..2cc47268 100644 --- a/src/prompt/build-prompt.test.ts +++ b/src/prompt/build-prompt.test.ts @@ -16,6 +16,11 @@ import type { } from "./stable-prefix.js"; import { estimateTokens, truncateToTokens } from "./token-budget.js"; import { ALSO_AVAILABLE_VIA_TOOL_VIEW } from "./stable-prefix.js"; +import { + REQUEST_FOLLOW_UP_MARKER, + REQUEST_SECTION_CHAR_BUDGET, + requestInView, +} from "./request-section.js"; function mkSession(overrides: Partial = {}): SessionState { const base = createEmptySessionState({ @@ -1857,3 +1862,107 @@ describe("buildPrompt structured form (`messages`)", () => { expect(prompt.messages.turns.length).toBe(turns.length - prompt.droppedTurns); }); }); + +describe("### request pins the operator's request once its carrier is dropped (F22)", () => { + const SPEC = `Build the asteroids game: ${"spec ".repeat(600)}`.trim(); + + function repairSession(): SessionState { + // A long first turn (the spec), a wall of tool traffic, then the + // repair message that starts the current turn. Under a small cap the + // packer keeps the last user turn and drops the spec's. + const turns: SessionState["turns"] = [{ kind: "user", text: SPEC, at: 1 }]; + for (let i = 0; i < 60; i += 1) { + turns.push({ kind: "assistant_tool_call", tool: "os.fs.read", args: { path: `f${i}` }, at: 2 + i }); + turns.push({ kind: "tool_result", tool: "os.fs.read", status: "ok", summary: `${"x".repeat(120)} ${i}`, at: 2 + i }); + } + turns.push({ kind: "assistant_reply", text: "built", at: 100 }); + turns.push({ kind: "user", text: "fix these bugs", at: 101 }); + return { ...mkSession(), turns }; + } + + const base = { + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + }; + + it("renders the section immediately before ### conversation only when the carrier was dropped", () => { + const dropped = buildPrompt({ + ...base, + session: repairSession(), + conversationMaxTokens: 600, + originalRequest: SPEC, + }); + expect(dropped.droppedTurns).toBeGreaterThan(0); + const tail = dropped.tail; + const world = tail.indexOf("### world"); + const request = tail.indexOf("### request"); + const conversation = tail.indexOf("### conversation"); + expect(request).toBeGreaterThan(world); + expect(conversation).toBeGreaterThan(request); + expect(tail.slice(request, conversation)).toContain("Build the asteroids game"); + expect(tail.slice(request, conversation)).toContain("has been dropped from the conversation below"); + // Its room came out of the conversation cap: the tail still fits. + expect(dropped.tokens.conversation).toBeLessThanOrEqual(dropped.conversationCapEffective); + + const inView = buildPrompt({ + ...base, + session: repairSession(), + conversationMaxTokens: 32_000, + originalRequest: SPEC, + }); + expect(inView.droppedTurns).toBe(0); + expect(inView.tail).not.toContain("### request"); + // The section is rendered from the record, not from the transcript, + // so a dropped carrier and no record costs nothing either. + expect( + buildPrompt({ ...base, session: repairSession(), conversationMaxTokens: 600 }).tail, + ).not.toContain("### request"); + }); + + it("reads a follow-up record by the turn it was taken from", () => { + // `pickOriginalRequest` combines the previous message with a short + // follow-up; the carrier to look for is the previous message. + const combined = `${SPEC}\n\n${REQUEST_FOLLOW_UP_MARKER}\nfix these bugs`; + const dropped = buildPrompt({ + ...base, + session: repairSession(), + conversationMaxTokens: 600, + originalRequest: combined, + }); + expect(dropped.tail).toContain("### request"); + const inView = buildPrompt({ + ...base, + session: repairSession(), + conversationMaxTokens: 32_000, + originalRequest: combined, + }); + expect(inView.tail).not.toContain("### request"); + }); + + it("clips the section at 16,000 chars and says so", () => { + const long = "L".repeat(REQUEST_SECTION_CHAR_BUDGET + 500); + const prompt = buildPrompt({ + ...base, + session: repairSession(), + conversationMaxTokens: 600, + originalRequest: long, + }); + const section = prompt.tail.slice( + prompt.tail.indexOf("### request"), + prompt.tail.indexOf("### conversation"), + ); + expect(section).toContain("L".repeat(REQUEST_SECTION_CHAR_BUDGET)); + expect(section).not.toContain("L".repeat(REQUEST_SECTION_CHAR_BUDGET + 1)); + expect(section).toContain("truncated: the request is 16,500 chars"); + }); + + it("requestInView matches the carrier by its trimmed text", () => { + const turns: SessionState["turns"] = [{ kind: "user", text: " hello ", at: 1 }]; + expect(requestInView("hello", turns)).toBe(true); + expect(requestInView("other", turns)).toBe(false); + expect(requestInView(" ", turns)).toBe(true); + expect(requestInView(`hello\n\n${REQUEST_FOLLOW_UP_MARKER}\ncontinue`, turns)).toBe(true); + expect(requestInView(`other\n\n${REQUEST_FOLLOW_UP_MARKER}\nhello`, turns)).toBe(false); + }); +}); diff --git a/src/prompt/build-prompt.ts b/src/prompt/build-prompt.ts index a787d78d..66476869 100644 --- a/src/prompt/build-prompt.ts +++ b/src/prompt/build-prompt.ts @@ -23,6 +23,7 @@ import type { BuiltPromptTruncationFlags, } from "./build-prompt-types.js"; import { resolveFusionMachineFacts } from "./fusion-machine-facts.js"; +import { renderRequestSection, requestInView } from "./request-section.js"; import { buildStablePrefix } from "./stable-prefix.js"; import { buildSessionSectionParts } from "./session-tail-sections.js"; import { renderLoadedToolsSection } from "./render-loaded-tools.js"; @@ -149,7 +150,9 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { // values, so they move only when the operator writes the config // file — the same event that already flips the fusion descriptor // gate and drops the KV cache once. - fusion: resolveFusionMachineFacts(config), + fusion: resolveFusionMachineFacts(config, { + workerSlots: input.liveWorkerSlots ?? null, + }), ...(turnFraming !== undefined ? { turnSystemOpen: turnFraming.systemOpen } : {}), @@ -290,20 +293,45 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { completionMaxTokens, }); - const packed = packConversation( + // One option set for every pack of this build: the `### request` + // re-pack below must cut under the same low-water mark and from the + // same remembered start, or the two packs could disagree about where + // the transcript begins. + const packOptions = { + maxPairs: conversationMaxPairs, + lowWater: conversationLowWater, + ...(input.session.macroTurnStarts + ? { macroTurnStarts: input.session.macroTurnStarts } + : {}), + ...(input.session.conversationPackStart + ? { packStart: input.session.conversationPackStart } + : {}), + }; + let packed = packConversation( input.session.turns, conversationCapEffective, - { - maxPairs: conversationMaxPairs, - lowWater: conversationLowWater, - ...(input.session.macroTurnStarts - ? { macroTurnStarts: input.session.macroTurnStarts } - : {}), - ...(input.session.conversationPackStart - ? { packStart: input.session.conversationPackStart } - : {}), - }, + packOptions, ); + // The operator's request, pinned only once the packer has dropped the + // turn that carried it. It then takes its room out of the conversation + // cap — a second pack, and only on that path — so the tail still fits + // the window; the carrier stays dropped under the smaller cap, so the + // decision cannot flip. + const request = input.originalRequest?.trim() ?? ""; + const requestSection = + request.length > 0 && !requestInView(request, packed.visibleTurns) + ? renderRequestSection(request) + : null; + if (requestSection !== null) { + const requestTokens = estimateTokens(requestSection); + if (requestTokens < conversationCapEffective) { + packed = packConversation( + input.session.turns, + conversationCapEffective - requestTokens, + packOptions, + ); + } + } const conversation = renderPackedConversation(packed); const taskPolicy = renderTaskPolicy({ userMessage: input.userMessage ?? null, @@ -335,6 +363,11 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { tailBefore.push("### recalled", recalled, ``); } tailBefore.push(`### world`, worldSnapshot, ``); + // The operator's request, immediately before the conversation, only + // while the packer has the turn that carried it out of view. + if (requestSection !== null) { + tailBefore.push(`### request`, requestSection, ``); + } const conversationParts = [`### conversation`, conversation, ``]; const tailAfter: string[] = []; if (profile !== null) { diff --git a/src/prompt/fusion-machine-facts.test.ts b/src/prompt/fusion-machine-facts.test.ts index 748b187e..69949c24 100644 --- a/src/prompt/fusion-machine-facts.test.ts +++ b/src/prompt/fusion-machine-facts.test.ts @@ -78,6 +78,30 @@ describe("resolveFusionMachineFacts", () => { expect(facts.workerTokenBudget).toBe(workerSlotFootprint()); }); + it("states the observed pool for an external server, and only then (F21)", () => { + // The server's own `/props` answer is an observation, not a guess: + // run 13's orchestrator planned with no slot count because the + // daemon ran in external mode, next to a server that had five. + const external = config({ mode: "external", parallel: 6 }); + expect(resolveFusionMachineFacts(external, { workerSlots: 5 }).workerSlots).toBe(5); + expect(resolveFusionMachineFacts(external, { workerSlots: null }).workerSlots).toBeNull(); + expect(resolveFusionMachineFacts(external, {}).workerSlots).toBeNull(); + // The config's own number wins where it has one; a cloud leg has none. + expect( + resolveFusionMachineFacts(config({ parallel: 3 }), { workerSlots: 5 }).workerSlots, + ).toBe(3); + expect( + resolveFusionMachineFacts(config({ parallel: "auto", contextSize: 0 }), { + workerSlots: 5, + }).workerSlots, + ).toBe(5); + const cloud = config({ + workerProvider: "openrouter", + providers: [{ id: "openrouter", kind: "openrouter" }] as unknown as Providers, + }); + expect(resolveFusionMachineFacts(cloud, { workerSlots: 5 }).workerSlots).toBeNull(); + }); + it("counts auto slots from a pinned context the way the daemon does", () => { expect( resolveFusionMachineFacts(config({ parallel: "auto", contextSize: 131_072 })) @@ -199,4 +223,30 @@ describe("the facts reaching the prompt", () => { expect(prompt.stablePrefix).toContain("~24K tokens"); expect(prompt.stablePrefix).toContain("`maxWorkers` at most 5"); }); + + it("threads the observed slot count into the block for an external server (F21)", () => { + const dir = mkdtempSync(join(tmpdir(), "fusion-facts-live-")); + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ + localModels: { mode: "external", managed: { modelId: "qwen-3.5-4b" } }, + }), + ); + process.env.ATOMIC_AGENT_STATE_DIR = dir; + resetConfigCache(); + const input = { + session: createEmptySessionState({ id: "s", workingDir: "/work" }), + toolDescriptors: [ + { name: "fusion.delegate", summary: "fan out", argsSchema: "{}" }, + ] satisfies ToolDescriptor[], + capabilities: { platform: "linux" } as unknown as CapabilitiesSummary, + skillCatalog: [], + }; + const before = buildPrompt(input); + expect(before.stablePrefix).toContain("### fusion"); + expect(before.stablePrefix).not.toContain("request slot"); + const after = buildPrompt({ ...input, liveWorkerSlots: 5 }); + expect(after.stablePrefix).toContain("5 request slots"); + expect(after.stablePrefix).toContain("`maxWorkers` at most 5"); + }); }); diff --git a/src/prompt/fusion-machine-facts.ts b/src/prompt/fusion-machine-facts.ts index c8289afb..0df94569 100644 --- a/src/prompt/fusion-machine-facts.ts +++ b/src/prompt/fusion-machine-facts.ts @@ -78,13 +78,30 @@ function nonEmpty(value: string | null | undefined): string | null { } /** - * Read the facts from an already-loaded config. + * What the runtime has observed, as opposed to what the config says. * - * Pure over its argument so it is testable without touching the config + * `workerSlots` is the local llama-server's request-slot count as the + * server itself reported it (`SlotManager.observedPoolSize`), `null` + * until a `/props` answer has sized the pool. It fills the one gap the + * config leaves — an external server, whose `--parallel` the operator + * chose out of band — and it is observed, never guessed, so it may be + * stated. It moves once, when first observed, and the prefix moves with + * it: the same one-time cost as a config write. + */ +export interface FusionLiveFacts { + workerSlots?: number | null; +} + +/** + * Read the facts from an already-loaded config, plus what the runtime + * has observed where the config is silent. + * + * Pure over its arguments so it is testable without touching the config * cache; `buildPrompt` passes the `getConfig()` it already holds. */ export function resolveFusionMachineFacts( config: AtomicAgentConfig, + live: FusionLiveFacts = {}, ): FusionMachineFacts { const local = config.localModels; const fusion = config.llm?.runMode?.fusion; @@ -124,7 +141,7 @@ export function resolveFusionMachineFacts( const pinnedContext = local.mode === "managed" ? local.managed.contextSize : 0; // Same inputs `buildLlamaServerArgs` counts slots from, so the number // stated here is the number the daemon launches with. - const workerSlots = + const configuredSlots = configured === null ? null : configured === "auto" @@ -136,6 +153,17 @@ export function resolveFusionMachineFacts( }) : null : configured; + // Where the config cannot say (external mode; managed `"auto"` with the + // context sized at start-up), the server's own answer may — that is an + // observation, not a guess. `null` only when nothing at all is known. + const observedSlots = + workersAreLocal && + typeof live.workerSlots === "number" && + Number.isFinite(live.workerSlots) && + live.workerSlots > 0 + ? live.workerSlots + : null; + const workerSlots = configuredSlots ?? observedSlots; // What one worker needs from the pool is a fact about the worker, not // the server, so it holds for an external llama-server too. diff --git a/src/prompt/request-section.ts b/src/prompt/request-section.ts new file mode 100644 index 00000000..46f0699b --- /dev/null +++ b/src/prompt/request-section.ts @@ -0,0 +1,71 @@ +import type { ConversationTurn } from "../session/conversation-turn.js"; + +/** + * The operator's request stays in view across repairs. + * + * The packer keeps the last user turn and the current task's opening + * turn; an earlier task's request can be dropped. During a repair that + * is exactly the turn that carried the spec — "four older turns were + * dropped, including the user message" — and the model then repaired + * against a summary line. The runtime already records the request per + * turn for the workers' briefs (`pickOriginalRequest`); this renders + * the same record as `### request`, immediately before + * `### conversation`, only when the packer has dropped the user turn + * that carried it. While the carrier is in view it costs nothing. + */ + +/** Same 16,000-char clip as the workers' quoted request. */ +export const REQUEST_SECTION_CHAR_BUDGET = 16_000; + +/** + * The line `pickOriginalRequest` puts between the previous user message + * and a short follow-up that started the turn. Split on it to find the + * turn that actually carried the request. + */ +export const REQUEST_FOLLOW_UP_MARKER = + "[the operator's latest message, which started this turn]"; + +/** + * The user-turn text the request was taken from: the message before a + * short follow-up when the record combines two, else the record itself. + */ +export function requestCarrierText(request: string): string { + const marker = `\n\n${REQUEST_FOLLOW_UP_MARKER}\n`; + const at = request.indexOf(marker); + return (at === -1 ? request : request.slice(0, at)).trim(); +} + +/** Whether the turn that carried `request` is among `visibleTurns`. */ +export function requestInView( + request: string, + visibleTurns: readonly ConversationTurn[], +): boolean { + const carrier = requestCarrierText(request); + if (carrier.length === 0) return true; + return visibleTurns.some( + (turn) => turn.kind === "user" && turn.text.trim() === carrier, + ); +} + +function formatCount(n: number): string { + return n.toLocaleString("en-US"); +} + +/** The `### request` body: the record, clipped, with a one-line frame. */ +export function renderRequestSection(request: string): string { + const text = request.trim(); + const clipped = + text.length > REQUEST_SECTION_CHAR_BUDGET + ? text.slice(0, REQUEST_SECTION_CHAR_BUDGET) + : text; + const lines = [ + "The operator's request that started this task. The turn that carried it has been dropped from the conversation below; this is what the work must still satisfy.", + clipped, + ]; + if (clipped.length < text.length) { + lines.push( + `(truncated: the request is ${formatCount(text.length)} chars; only the first ${formatCount(REQUEST_SECTION_CHAR_BUDGET)} are shown.)`, + ); + } + return lines.join("\n"); +} diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index e8e34786..8b5b68af 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -96,6 +96,7 @@ import { CostAccumulator } from "../llm/provider/cost-accumulator.js"; import { modelWantsStrictTools } from "../llm/provider/model-strict-tools.js"; import type { ResolvedModel } from "../llm/provider/model-resolver.js"; import { resolveModelPricingFor } from "./resolve-model-pricing.js"; +import type { ReasoningEffort } from "../llm/provider/completion-types.js"; import { ProviderFallbackChain, resolveFallbackChain, @@ -533,6 +534,10 @@ export interface AgentRuntime { toolFilter?: (name: string) => boolean; /** The turn's tool role (see `RunTurnOptions.toolRole`); a worker is a `builder`. */ toolRole?: ToolRole; + /** See `RunTurnOptions.reasoningEffort` — a fusion worker's setting. */ + reasoningEffort?: ReasoningEffort; + /** See `RunTurnOptions.maxOutputTokens` — a fusion worker's cap. */ + maxOutputTokens?: number; }, ): Promise; /** @@ -558,6 +563,8 @@ export interface AgentRuntime { taskMaxDurationMs?: number; toolFilter?: (name: string) => boolean; toolRole?: ToolRole; + reasoningEffort?: ReasoningEffort; + maxOutputTokens?: number; }, ): Promise; /** @@ -2372,6 +2379,9 @@ export async function createAgentRuntime( capabilities, profile, contextWindow: resolveCatalogContextWindow, + // The local leg's slot count once `/props` has answered — what the + // `### fusion` facts state for an external server. + liveWorkerSlots: () => slotManager.observedPoolSize(), onContextWindowObserved: observeContextWindow, onContextWindowExceeded: forgetContextWindowBelow, // A pinned turn (`RunTurnOptions.providerId`, a fusion worker on the @@ -2755,7 +2765,15 @@ export async function createAgentRuntime( taskMaxDurationMs?: number; toolFilter?: (name: string) => boolean; toolRole?: ToolRole; + reasoningEffort?: ReasoningEffort; + maxOutputTokens?: number; }) => ({ + ...(runOptions.reasoningEffort === undefined + ? {} + : { reasoningEffort: runOptions.reasoningEffort }), + ...(runOptions.maxOutputTokens === undefined + ? {} + : { maxOutputTokens: runOptions.maxOutputTokens }), maxSteps: Math.min( config.agent.maxSteps, runOptions.maxSteps ?? config.agent.maxSteps, @@ -2803,6 +2821,8 @@ export async function createAgentRuntime( taskMaxDurationMs?: number; toolFilter?: (name: string) => boolean; toolRole?: ToolRole; + reasoningEffort?: ReasoningEffort; + maxOutputTokens?: number; } = {}, ): Promise => { assertKnownProvider(runOptions.providerId); @@ -2862,6 +2882,9 @@ export async function createAgentRuntime( // at the first checkpoint. const result = await loop.runTurn(session, { userMessage, + // The same record the workers' briefs quote, pinned into the + // orchestrator's own prompt once the packer drops its carrier. + ...(turnRequest !== undefined ? { originalRequest: turnRequest } : {}), ...buildLoopTurnBudget(runOptions), }); // Stamp the turn's window occupancy so the stored session can @@ -2931,6 +2954,8 @@ export async function createAgentRuntime( taskMaxDurationMs?: number; toolFilter?: (name: string) => boolean; toolRole?: ToolRole; + reasoningEffort?: ReasoningEffort; + maxOutputTokens?: number; } = {}, ): Promise => { // Before the queue, so a bad pin rejects now rather than after @@ -3061,6 +3086,15 @@ export async function createAgentRuntime( runTurn(session, userMessage, turnOptions), createEphemeralSession, resolveOriginalRequest: (sessionId) => turnRequests.get(sessionId), + // The worker leg's pricing, when the catalogue or a hand-priced + // entry knows it — the status table's spend line. + resolveWorkerPricing: (providerId, modelId) => + resolveModelPricingFor(resolveLlmConfig(getConfig()), modelId, providerId) + ?.pricing, + // The same client the llama-server provider serves workers with, + // so the speed a worker's time limit is sized from is the speed + // its own completions run at. + localTokensPerSecond: () => llama.measuredTokensPerSecond(), approvals, approvalRequired: dangerous.approvalRequired, slotManager, diff --git a/src/runtime/llm-fallback-seam.test.ts b/src/runtime/llm-fallback-seam.test.ts index ca30dca4..21941851 100644 --- a/src/runtime/llm-fallback-seam.test.ts +++ b/src/runtime/llm-fallback-seam.test.ts @@ -141,6 +141,38 @@ describe("createFallbackStreamer (real bootstrap seam)", () => { expect(seen).toEqual([32_768, undefined]); }); + it("forwards the turn's reasoning effort and output ceiling on both transports (F20)", async () => { + const seen: Array<{ effort?: string; cap?: number }> = []; + const observe = async (request: { reasoningEffort?: string; maxOutputTokens?: number }) => { + seen.push({ + ...(request.reasoningEffort === undefined ? {} : { effort: request.reasoningEffort }), + ...(request.maxOutputTokens === undefined ? {} : { cap: request.maxOutputTokens }), + }); + }; + const providers = new Map([ + [ + "cloud", + fakeProvider("cloud", "native_tools", async (request) => { + await observe(request); + return answer("cloud"); + }), + ], + [ + "local", + fakeProvider("local", "grammar", async (request) => { + await observe(request); + return answer("local"); + }), + ], + ]); + const streamer = createFallbackStreamer(seamDeps(providers)); + const complete = createFallbackCompleter(seamDeps(providers)); + await drain(streamer({ ...baseParams, reasoningEffort: "low", maxOutputTokens: 12_000 })); + await complete({ ...baseParams, providerId: "local", reasoningEffort: "high", maxOutputTokens: 9_000 }); + await drain(streamer(baseParams)); + expect(seen).toEqual([{ effort: "low", cap: 12_000 }, { effort: "high", cap: 9_000 }, {}]); + }); + async function drain( gen: AsyncGenerator, ): Promise { diff --git a/src/runtime/llm-link-attempt.ts b/src/runtime/llm-link-attempt.ts index 6a662347..363b4d18 100644 --- a/src/runtime/llm-link-attempt.ts +++ b/src/runtime/llm-link-attempt.ts @@ -71,6 +71,22 @@ function grammarRequestFields(params: LlmStreamParams) { }; } +/** + * The turn's own settings, forwarded on both transports: the output + * ceiling caps llama-server's `n_predict` as much as a cloud + * `max_tokens`, and the effort is mapped (or dropped) per provider. + */ +function turnRequestFields(params: LlmStreamParams) { + return { + ...(typeof params.maxOutputTokens === "number" + ? { maxOutputTokens: params.maxOutputTokens } + : {}), + ...(params.reasoningEffort !== undefined + ? { reasoningEffort: params.reasoningEffort } + : {}), + }; +} + /** * One unary attempt against `providerId`: warm the link, resolve its * transport, send the request in that transport's shape. Returns the @@ -90,6 +106,7 @@ export async function completeOnLink( ...(typeof params.maxTokens === "number" ? { maxTokens: params.maxTokens } : {}), + ...turnRequestFields(params), ...(params.signal ? { signal: params.signal } : {}), }; const result = @@ -132,6 +149,7 @@ export async function openStreamOnLink( ...(typeof params.maxTokens === "number" ? { maxTokens: params.maxTokens } : {}), + ...turnRequestFields(params), ...(params.signal ? { signal: params.signal } : {}), }; const stream = diff --git a/src/tools/fusion/fusion-delegate.test.ts b/src/tools/fusion/fusion-delegate.test.ts index 89d3e886..208b0f29 100644 --- a/src/tools/fusion/fusion-delegate.test.ts +++ b/src/tools/fusion/fusion-delegate.test.ts @@ -335,12 +335,117 @@ describe("fusion.delegate", () => { deps({ slotManager: { poolSize: () => 1 }, workerSupportsSlotAffinity: () => false, + resolveRunMode: () => fusionMode({ cloudWorkers: 8 }), }), ); const result = await tool.run({ tasks: sixTasks(), maxWorkers: 6 }, ctx()); expect(result.details.maxWorkers).toBe(6); expect(result.details.slotPoolSize).toBeUndefined(); expect(result.summary).not.toContain("localModels.managed.parallel"); + expect(result.summary).not.toContain("cloudWorkers"); + }); + + it("hands the worker reasoning and cap to every worker turn, and prices the fan-out (F20)", async () => { + const seen: Array> = []; + const tool = buildFusionDelegateTool( + deps({ + runTurn: async (_session, _message, options) => { + seen.push({ ...options }); + options.eventHook?.({ + type: "llm_event", + event: { + type: "llm_completed", + completion: { + content: "", + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 1, predictedTokens: 1 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "small", + usage: { promptTokens: 1_000_000, completionTokens: 250_000, totalTokens: 1_250_000 }, + }, + }, + }); + return turnResult(); + }, + resolveRunMode: () => + fusionMode({ workerReasoning: "low", workerMaxOutputTokens: 12_000 }), + resolveWorkerPricing: (providerId, modelId) => + providerId === "local-llama" && modelId === "small" + ? { input: 1, output: 4 } + : undefined, + }), + ); + const result = await tool.run({ tasks: TASKS }, ctx()); + expect(seen).toHaveLength(2); + expect(seen[0]).toMatchObject({ reasoningEffort: "low", maxOutputTokens: 12_000 }); + expect(result.summary).toContain("cloud spend $4.00 on small (2,000,000 in / 500,000 out)"); + expect(result.details.workerSpendUsd).toBeCloseTo(4); + }); + + it("sends no reasoning or cap and no spend line when nothing is configured or priced (F20)", async () => { + const seen: Array> = []; + const tool = buildFusionDelegateTool( + deps({ + runTurn: async (_session, _message, options) => { + seen.push({ ...options }); + return turnResult(); + }, + }), + ); + const result = await tool.run({ tasks: TASKS }, ctx()); + expect(seen[0]).not.toHaveProperty("reasoningEffort"); + expect(seen[0]).not.toHaveProperty("maxOutputTokens"); + expect(result.summary).not.toContain("cloud spend"); + expect(result.details).not.toHaveProperty("workerSpendUsd"); + }); + + it("sizes a local worker's time limit from the measured speed, a cloud one from the ceiling (F19)", async () => { + const limits: Array = []; + const capture = (over: Partial) => + buildFusionDelegateTool( + deps({ + runTurn: async (_s, _m, options) => { + limits.push(options.taskMaxDurationMs); + return turnResult(); + }, + resolveRunMode: () => fusionMode({ workerTimeoutMs: 2_700_000 }), + localTokensPerSecond: () => 10, + ...over, + }), + ); + const task = { id: "t1", title: "One", instructions: "Do one", files: ["a.js", "b.js"] }; + await capture({}).run({ tasks: [task] }, ctx()); + expect(limits[0]).toBeGreaterThanOrEqual(600_000); + expect(limits[0]).toBeLessThan(2_700_000); + await capture({ workerSupportsSlotAffinity: () => false }).run({ tasks: [task] }, ctx()); + expect(limits[1]).toBe(2_700_000); + await capture({ localTokensPerSecond: () => null }).run({ tasks: [task] }, ctx()); + expect(limits[2]).toBe(2_700_000); + }); + + it("clamps a cloud fan-out to cloudWorkers and says so (F21)", async () => { + // A cloud leg has no slot pool, so before this the width was whatever + // the model asked for — three workers from a one-worker config, and + // no ceiling on forty. Default cap 4; the note names the knob. + const tool = buildFusionDelegateTool( + deps({ + slotManager: { poolSize: () => 1 }, + workerSupportsSlotAffinity: () => false, + }), + ); + const result = await tool.run({ tasks: sixTasks(), maxWorkers: 6 }, ctx()); + expect(result.details.maxWorkers).toBe(4); + expect(result.details.requestedWorkers).toBe(6); + expect(result.summary).toContain( + "maxWorkers 6 was clamped to 4, the cloud worker cap (`llm.runMode.fusion.cloudWorkers`)", + ); + // A call that names nothing keeps `workers` as its default, under the cap. + const quiet = await tool.run({ tasks: sixTasks() }, ctx()); + expect(quiet.details.maxWorkers).toBe(3); + expect(quiet.summary).not.toContain("cloudWorkers"); }); it("names both numbers and the knob when the pool is the binding constraint", async () => { diff --git a/src/tools/fusion/fusion-delegate.ts b/src/tools/fusion/fusion-delegate.ts index da72cf76..db179be8 100644 --- a/src/tools/fusion/fusion-delegate.ts +++ b/src/tools/fusion/fusion-delegate.ts @@ -7,6 +7,7 @@ import type { ResolvedRunMode } from "../../llm/run-mode/index.js"; import type { SlotManager } from "../../llm/slot-manager.js"; import type { StructuredLogger } from "../../tracing/index.js"; import { isFusionWorkerSessionId } from "../../session/fusion-worker-session.js"; +import { DEFAULT_FUSION_CLOUD_WORKERS } from "../../config/llm-run-mode-config.js"; import type { ToolDefinition } from "../tool-registry.js"; import { parseDelegateArgs } from "./delegate-args.js"; import { @@ -21,7 +22,9 @@ import { import { runWorkerTasks, type WorkerRunnerDeps } from "./worker-runner.js"; import { delegateOutcome, + fanoutSpend, formatDelegateOutput, + type WorkerPricing, type WorkerTaskResult, } from "./worker-result.js"; @@ -58,6 +61,23 @@ export interface FusionDelegateDeps extends WorkerRunnerDeps { * have passed. */ runChecks?: ContractCheckRunner; + /** + * Pricing for the worker model on the worker leg, when any is known + * (`resolveModelPricingFor`). Present, the status table header states + * the fan-out's spend; a local leg resolves to nothing. + */ + resolveWorkerPricing?: ( + providerId: string, + modelId: string, + ) => WorkerPricing | undefined; + /** + * The local leg's measured generation speed + * (`LlamaServerClient.measuredTokensPerSecond`), read per fan-out so a + * worker's time limit follows the machine's current load. Only + * consulted for a slot-affine (local) leg; `null` before any + * completion has been measured. + */ + localTokensPerSecond?: () => number | null; } function error( @@ -199,12 +219,21 @@ export function buildFusionDelegateTool( parsed.maxWorkers ?? (Number.isFinite(poolSize) ? (poolSize as number) : mode.workers); const wanted = Math.max(1, Math.min(requested, parsed.tasks.length)); - const maxWorkers = Math.max(1, Math.min(wanted, poolSize)); + // A cloud leg has no slot pool, so nothing physical bounds the + // width — only the bill. `cloudWorkers` is that bound: a + // `maxWorkers` above it is clamped, and the result says so, since + // the orchestrator is the party that can re-plan around it. + const cloudCap = Number.isFinite(poolSize) + ? Number.POSITIVE_INFINITY + : (mode.cloudWorkers ?? DEFAULT_FUSION_CLOUD_WORKERS); + const maxWorkers = Math.max(1, Math.min(wanted, poolSize, cloudCap)); + const cloudCapIsBinding = maxWorkers < wanted && maxWorkers === cloudCap; // The pool held this fan-out down when it ran fewer at a time than // there was work for — whether the orchestrator asked for a wider // number or simply had more tasks than the machine has slots. const poolIsBinding = - maxWorkers < wanted || maxWorkers < parsed.tasks.length; + !cloudCapIsBinding && + (maxWorkers < wanted || maxWorkers < parsed.tasks.length); // Labels, never guesses: the resolver's pin when it has one, the // provider id when it does not. Both legs are read from the same @@ -279,6 +308,12 @@ export function buildFusionDelegateTool( // enough that workers built the wrong thing or scavenged the disk // for the missing spec. Every worker also gets what was asked. const originalRequest = deps.resolveOriginalRequest?.(ctx.sessionId); + // A local worker's time limit is sized from the machine's measured + // speed (F19); a cloud leg has no such measurement and keeps the + // configured ceiling. + const localTokensPerSecond = Number.isFinite(poolSize) + ? (deps.localTokensPerSecond?.() ?? null) + : null; let results: WorkerTaskResult[]; try { @@ -292,6 +327,13 @@ export function buildFusionDelegateTool( workerModel, workerMaxSteps: mode.workerMaxSteps, workerTimeoutMs: mode.workerTimeoutMs, + localTokensPerSecond, + ...(mode.workerReasoning === undefined + ? {} + : { workerReasoning: mode.workerReasoning }), + ...(mode.workerMaxOutputTokens === undefined + ? {} + : { workerMaxOutputTokens: mode.workerMaxOutputTokens }), writeScope, signal: ctx.signal, }); @@ -354,17 +396,26 @@ export function buildFusionDelegateTool( // concurrently, and the config key that changes the second number. const hint = poolIsBinding ? `\n\nNote: ${Math.max(wanted, parsed.tasks.length)} workers' worth of work was sent but the local server has ${poolSize} request slot${poolSize === 1 ? "" : "s"}, so only ${maxWorkers} ran at a time and the rest queued. That number comes from the machine — every slot draws on one shared llama-server context pool (\`localModels.managed.parallel\`, \`"auto"\` by default). Split into fewer, larger tasks if the queueing is costing more than the parallelism buys.` - : ""; + : cloudCapIsBinding + ? `\n\nNote: maxWorkers ${wanted} was clamped to ${maxWorkers}, the cloud worker cap (\`llm.runMode.fusion.cloudWorkers\`); the rest queued behind them.` + : ""; // The call's own status is the tasks' summary: a fan-out where // every worker failed used to come back `ok`, and an orchestrator // reading only the status merged nothing as if it were something. const outcome = delegateOutcome(results); + // What the fan-out cost on the worker leg, when its model is priced + // (a cloud leg with a catalogue entry); a local leg resolves to no + // pricing and the header says nothing. + const pricing = deps.resolveWorkerPricing?.(workerProviderId, workerModel); + const spend = + pricing === undefined ? null : fanoutSpend(results, pricing, workerModel); return compressToolResult( { tool: FUSION_DELEGATE_TOOL, status: outcome === "all_failed" ? "error" : "ok", output: `${formatDelegateOutput(results, deps.outputCharCap, { ...(contractLine === undefined ? {} : { contractLine }), + spend, })}${hint}`, details: { tasks: results, @@ -373,6 +424,7 @@ export function buildFusionDelegateTool( requestedWorkers: requested, ...(Number.isFinite(poolSize) ? { slotPoolSize: poolSize } : {}), ...(contract === undefined ? {} : { contract }), + ...(spend === null ? {} : { workerSpendUsd: spend.usd }), }, }, { maxSummaryLength: deps.outputCharCap + 400, maxTailLines: 2000 }, diff --git a/src/tools/fusion/worker-prompt.ts b/src/tools/fusion/worker-prompt.ts index f1d4c279..6b6b8756 100644 --- a/src/tools/fusion/worker-prompt.ts +++ b/src/tools/fusion/worker-prompt.ts @@ -1,4 +1,5 @@ import type { ConversationTurn } from "../../session/conversation-turn.js"; +import { REQUEST_FOLLOW_UP_MARKER } from "../../prompt/request-section.js"; import type { DelegateTask } from "./delegate-args.js"; import { renderContractBlock, @@ -81,7 +82,7 @@ export function pickOriginalRequest(input: { if (current.length >= FOLLOW_UP_MAX_CHARS || previous === undefined) { return current; } - return `${previous}\n\n[the operator's latest message, which started this turn]\n${current}`; + return `${previous}\n\n${REQUEST_FOLLOW_UP_MARKER}\n${current}`; } function quoteOriginalRequest(request: string): string[] { diff --git a/src/tools/fusion/worker-result.test.ts b/src/tools/fusion/worker-result.test.ts index 8661d1e3..bfc75e5b 100644 --- a/src/tools/fusion/worker-result.test.ts +++ b/src/tools/fusion/worker-result.test.ts @@ -8,6 +8,7 @@ import { WorkerRunCollector, classifyWorkerStatus, delegateOutcome, + fanoutSpend, formatDelegateOutput, resultCarriesApprovalRefusal, workerFailureHint, @@ -240,6 +241,29 @@ describe("delegateOutcome", () => { }); }); +describe("fan-out spend in the status table header (F20)", () => { + it("prices the tasks' usage and states it on the head line", () => { + const results = [ + row({ usage: { promptTokens: 400_000, completionTokens: 100_000, totalTokens: 500_000 } }), + row({ id: "t2", usage: { promptTokens: 100_000, completionTokens: 50_000, totalTokens: 150_000 } }), + row({ id: "t3" }), // died before its first completion: no usage + ]; + const spend = fanoutSpend(results, { input: 1, output: 4 }, "z-ai/glm-5.3-flash"); + expect(spend).toEqual({ + usd: 0.5 + 0.6, + model: "z-ai/glm-5.3-flash", + promptTokens: 500_000, + completionTokens: 150_000, + }); + const out = formatDelegateOutput(results, 4000, { spend }); + expect(out.split("\n")[0]).toBe( + "3 tasks: 3 ok — cloud spend $1.10 on z-ai/glm-5.3-flash (500,000 in / 150,000 out)", + ); + // Without pricing the head line is as it was. + expect(formatDelegateOutput(results, 4000).split("\n")[0]).toBe("3 tasks: 3 ok"); + }); +}); + describe("formatDelegateOutput", () => { it("renders one headed block per task", () => { const out = formatDelegateOutput( diff --git a/src/tools/fusion/worker-result.ts b/src/tools/fusion/worker-result.ts index 630211b0..ed47f8be 100644 --- a/src/tools/fusion/worker-result.ts +++ b/src/tools/fusion/worker-result.ts @@ -154,6 +154,8 @@ export class WorkerRunCollector { private usage: CompletionUsage | undefined; private lastLoopError: string | undefined; private lastWaitReason: string | undefined; + /** The last few tool results, one line each — what a hand-back reports. */ + private readonly recent: string[] = []; /** Feed one `AgentLoopEvent` from the worker turn's hook. */ observe(event: AgentLoopEvent): void { @@ -193,6 +195,10 @@ export class WorkerRunCollector { if (resultCarriesApprovalRefusal(result.summary, result.details)) { this.approvalRefused = true; } + this.recent.push( + `${result.tool} ${result.status}: ${oneLine(result.summary, FINDING_CHARS)}`, + ); + if (this.recent.length > FINDINGS_KEPT) this.recent.shift(); return; } if (inner.type === "llm_completed" && inner.completion.usage) { @@ -209,6 +215,25 @@ export class WorkerRunCollector { } } + /** + * What the worker saw before it was handed back: the tool tally and + * its last few results. The orchestrator re-briefs from this rather + * than from nothing. + */ + findings(): string { + const tally = Object.entries(this.byTool) + .map(([tool, n]) => `${tool}×${n}`) + .join(", "); + const parts = [ + this.calls === 0 ? "no tool calls" : `${this.calls} tool calls (${tally})`, + ...(this.recent.length > 0 ? [`last results: ${this.recent.join(" | ")}`] : []), + ...(this.replyText.length > 0 + ? [`partial reply: ${oneLine(this.replyText, FINDING_CHARS)}`] + : []), + ]; + return parts.join("; "); + } + /** The worker loop's own account of why it stopped working, if any. */ private failureMessage(): string | undefined { if (this.lastLoopError !== undefined) return this.lastLoopError; @@ -350,6 +375,10 @@ export function workerFailureHint(message: string): string | undefined { const NO_REPLY = "(the worker produced no reply)"; +/** How many tool results a hand-back's findings keep, and how much of each. */ +const FINDINGS_KEPT = 6; +const FINDING_CHARS = 160; + /** How much of an error the head line carries; the rest is noise. */ const ERROR_HEAD_CHARS = 400; @@ -373,10 +402,10 @@ const ERROR_HEAD_CHARS = 400; export function formatDelegateOutput( results: readonly WorkerTaskResult[], charCap: number, - extra: { contractLine?: string } = {}, + extra: DelegateOutputExtras = {}, ): string { if (results.length === 0) return "(no tasks were run)"; - const table = renderStatusTable(results, extra.contractLine); + const table = renderStatusTable(results, extra); const room = Math.max(0, charCap - table.length - 4); const perTask = Math.max(200, Math.floor(room / results.length)); const blocks = results.map((r) => renderBlock(r, perTask)); @@ -388,16 +417,67 @@ export function formatDelegateOutput( /** How much of an error or a note one status-table line carries. */ const TABLE_DETAIL_CHARS = 160; +/** USD per million tokens, as the model catalogue / `userModels[].pricing` state it. */ +export interface WorkerPricing { + input: number; + output: number; +} + +/** What a fan-out cost on its worker leg, for the status table header. */ +export interface FanoutSpend { + usd: number; + model: string; + promptTokens: number; + completionTokens: number; +} + /** - * The head line, the contract's verdict when there is one, then one - * line per task. The contract line sits second because it is the one - * cross-task fact: a missing provide is a hole between parts, not a - * property of any single row. + * Σ over the tasks' usage at `pricing`, the same arithmetic as the + * turn cost line (`turn-usage-meter.ts`). Tasks with no usage (a turn + * that died before its first completion) contribute nothing. + */ +export function fanoutSpend( + results: readonly WorkerTaskResult[], + pricing: WorkerPricing, + model: string, +): FanoutSpend { + let promptTokens = 0; + let completionTokens = 0; + for (const r of results) { + if (r.usage === undefined) continue; + promptTokens += r.usage.promptTokens; + completionTokens += r.usage.completionTokens; + } + const usd = + (promptTokens / 1_000_000) * pricing.input + + (completionTokens / 1_000_000) * pricing.output; + return { usd, model, promptTokens, completionTokens }; +} + +function formatUsd(usd: number): string { + return usd < 0.01 && usd > 0 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`; +} + +/** What the status table carries beyond the rows themselves. */ +export interface DelegateOutputExtras { + /** The contract's verdict (`renderContractLine`), when the fan-out had one. */ + contractLine?: string; + /** The fan-out's priced worker spend, when the worker model is priced. */ + spend?: FanoutSpend | null; +} + +/** + * The head line (with the bill, when there is one), the contract's + * verdict when there is one, then one line per task. The contract line + * sits second because it is the one cross-task fact: a missing provide + * is a hole between parts, not a property of any single row. */ function renderStatusTable( results: readonly WorkerTaskResult[], - contractLine: string | undefined, + extra: DelegateOutputExtras, ): string { + const { contractLine } = extra; + const spend = extra.spend ?? null; const counts = new Map(); for (const r of results) { counts.set(r.status, (counts.get(r.status) ?? 0) + 1); @@ -405,6 +485,12 @@ function renderStatusTable( const tally = WORKER_STATUS_ORDER.filter((status) => counts.has(status)) .map((status) => `${counts.get(status)} ${status}`) .join(", "); + // The bill, on the head line, where a capped read still sees it: a + // fan-out whose two workers wrote nothing cost $2.29 and nothing said so. + const cost = + spend === null + ? "" + : ` — cloud spend ${formatUsd(spend.usd)} on ${spend.model} (${spend.promptTokens.toLocaleString("en-US")} in / ${spend.completionTokens.toLocaleString("en-US")} out)`; const lines = results.map((r) => [ `- [${r.id}] ${r.status} — ${r.title}`, @@ -414,7 +500,7 @@ function renderStatusTable( ].join(" — "), ); return [ - `${results.length} task${results.length === 1 ? "" : "s"}: ${tally}`, + `${results.length} task${results.length === 1 ? "" : "s"}: ${tally}${cost}`, ...(contractLine === undefined ? [] : [contractLine]), ...lines, ].join("\n"); diff --git a/src/tools/fusion/worker-runner.test.ts b/src/tools/fusion/worker-runner.test.ts index 69f49e63..c25659ba 100644 --- a/src/tools/fusion/worker-runner.test.ts +++ b/src/tools/fusion/worker-runner.test.ts @@ -16,7 +16,9 @@ import { } from "../../session/fusion-worker-session.js"; import type { DelegateTask } from "./delegate-args.js"; import { + estimateWorkerTimeoutMs, runWorkerTasks, + WORKER_TIMEOUT_FLOOR_MS, WORKER_TOOL_LINES_PER_TASK, type WorkerRunnerDeps, } from "./worker-runner.js"; @@ -123,6 +125,31 @@ describe("runWorkerTasks", () => { }); }); + it("passes the worker reasoning and output cap into the turn, only when set (F20)", async () => { + const { deps, calls } = harness(async () => turnResult()); + await runWorkerTasks(deps, { + ...BASE, + tasks: tasks(1), + maxWorkers: 1, + workerReasoning: "low", + workerMaxOutputTokens: 12_000, + signal: new AbortController().signal, + }); + expect(calls[0]!.options).toMatchObject({ + reasoningEffort: "low", + maxOutputTokens: 12_000, + }); + const plain = harness(async () => turnResult()); + await runWorkerTasks(plain.deps, { + ...BASE, + tasks: tasks(1), + maxWorkers: 1, + signal: new AbortController().signal, + }); + expect(plain.calls[0]!.options).not.toHaveProperty("reasoningEffort"); + expect(plain.calls[0]!.options).not.toHaveProperty("maxOutputTokens"); + }); + it("runs on a fresh session id, never the parent's — that would deadlock", async () => { const { deps, calls } = harness(async () => turnResult()); await runWorkerTasks(deps, { @@ -717,3 +744,174 @@ describe("runWorkerTasks", () => { }); }); }); + +describe("worker limits from throughput (F19)", () => { + it("estimateWorkerTimeoutMs sizes from the brief, the declared files and the measured speed", () => { + const ceilingMs = 2_700_000; + // No measurement (or a cloud leg): the ceiling, as before. + expect(estimateWorkerTimeoutMs({ briefChars: 4000, declaredFiles: 2, tokensPerSecond: null, ceilingMs })).toBe(ceilingMs); + expect(estimateWorkerTimeoutMs({ briefChars: 4000, declaredFiles: 2, tokensPerSecond: 0, ceilingMs })).toBe(ceilingMs); + // (4000/4 + 2×2000) tokens / 10 tok/s × 3 = 1,500 s = 25 min. + expect(estimateWorkerTimeoutMs({ briefChars: 4000, declaredFiles: 2, tokensPerSecond: 10, ceilingMs })).toBe(1_500_000); + // Fast machine, small task: the 10-minute floor. + expect(estimateWorkerTimeoutMs({ briefChars: 400, declaredFiles: 1, tokensPerSecond: 200, ceilingMs })).toBe(WORKER_TIMEOUT_FLOOR_MS); + // Slow machine, big task: the ceiling. + expect(estimateWorkerTimeoutMs({ briefChars: 16_000, declaredFiles: 8, tokensPerSecond: 3, ceilingMs })).toBe(ceilingMs); + // A ceiling below the floor is the operator's pin: honoured as is. + expect(estimateWorkerTimeoutMs({ briefChars: 400, declaredFiles: 1, tokensPerSecond: 200, ceilingMs: 60_000 })).toBe(60_000); + }); + + it("gives each worker turn its own estimated time limit on a measured local leg", async () => { + const { deps, calls } = harness(async () => turnResult()); + const withFiles = { ...tasks(1)[0]!, files: ["a.js", "b.js"] }; + await runWorkerTasks(deps, { + ...BASE, + workerTimeoutMs: 2_700_000, + localTokensPerSecond: 10, + tasks: [withFiles], + maxWorkers: 1, + signal: new AbortController().signal, + }); + const briefChars = calls[0]!.userMessage.length; + const expected = estimateWorkerTimeoutMs({ + briefChars, + declaredFiles: 2, + tokensPerSecond: 10, + ceilingMs: 2_700_000, + }); + expect(calls[0]!.options.taskMaxDurationMs).toBe(expected); + expect(expected).toBeGreaterThanOrEqual(WORKER_TIMEOUT_FLOOR_MS); + expect(expected).toBeLessThan(2_700_000); + + const unmeasured = harness(async () => turnResult()); + await runWorkerTasks(unmeasured.deps, { + ...BASE, + workerTimeoutMs: 2_700_000, + localTokensPerSecond: null, + tasks: [withFiles], + maxWorkers: 1, + signal: new AbortController().signal, + }); + expect(unmeasured.calls[0]!.options.taskMaxDurationMs).toBe(2_700_000); + }); +}); + +describe("early hand-back when nothing is written (D4 / F19)", () => { + const stepStarted = (stepIndex: number): AgentLoopEvent => ({ type: "step_started", stepIndex }); + const wrote = (tool = "os.fs.write"): AgentLoopEvent => ({ + type: "llm_event", + event: { + type: "tool_call_executed", + result: { tool, status: "ok", summary: "wrote a.js", details: {}, truncated: false }, + batchIndex: 0, + batchSize: 1, + }, + }); + const read = (): AgentLoopEvent => ({ + type: "llm_event", + event: { + type: "tool_call_executed", + result: { tool: "os.fs.read", status: "ok", summary: "read main.js: 40 lines", details: {}, truncated: false }, + batchIndex: 0, + batchSize: 1, + }, + }); + + /** A worker that reads at every step and writes at `writeAtStep`, if ever. */ + function stepping(writeAtStep: number | null) { + return async ({ options }: { options: Parameters[2] }) => { + for (let step = 1; step <= 8; step += 1) { + options.eventHook?.(stepStarted(step - 1)); + if (options.signal?.aborted) { + return turnResult({ reason: "cancelled", stepCount: step - 1 }); + } + options.eventHook?.(step === writeAtStep ? wrote() : read()); + } + options.eventHook?.({ type: "llm_event", event: { type: "assistant_reply", text: "done" } }); + return turnResult({ stepCount: 8 }); + }; + } + + it("hands a task with declared files back once half the steps pass with no write", async () => { + const { deps, events } = harness(stepping(null)); + const [result] = await runWorkerTasks(deps, { + ...BASE, + workerMaxSteps: 8, + tasks: [{ ...tasks(1)[0]!, files: ["a.js"] }], + maxWorkers: 1, + signal: new AbortController().signal, + }); + expect(result!.status).toBe("needs_orchestrator"); + // Half of 8 is 4: the fifth step is the one that is not taken. + expect(result!.reply).toMatch(/^handed back early: no file written by half the budget \(4 of 8 steps/); + expect(result!.reply).toContain("what I found: 4 tool calls (os.fs.read×4)"); + expect(result!.reply).toContain("read main.js: 40 lines"); + expect(result!.error).toBeUndefined(); + expect(result!.notes).toContainEqual( + expect.stringContaining("handed back early: declared files but wrote none"), + ); + const finished = events.map((e) => e.event).find((e) => e.type === "fusion_worker" && e.phase !== "started" && e.phase !== "tool"); + expect(finished).toMatchObject({ phase: "finished", summary: "needs the orchestrator" }); + }); + + it("lets a worker that wrote something before the half-way mark run on", async () => { + // The declared file exists afterwards, so the ground-truth check + // has nothing to downgrade and the status is the loop's own. + const workingDir = mkdtempSync(join(tmpdir(), "atomic-handback-")); + writeFileSync(join(workingDir, "a.js"), "ok\n"); + const { deps } = harness(stepping(3)); + deps.workingDir = workingDir; + const [result] = await runWorkerTasks(deps, { + ...BASE, + workerMaxSteps: 8, + tasks: [{ ...tasks(1)[0]!, files: ["a.js"] }], + maxWorkers: 1, + signal: new AbortController().signal, + }); + expect(result!.status).toBe("ok"); + expect(result!.stepCount).toBe(8); + }); + + it("never hands back a task that declared no files", async () => { + const { deps } = harness(stepping(null)); + const [result] = await runWorkerTasks(deps, { + ...BASE, + workerMaxSteps: 8, + tasks: tasks(1), + maxWorkers: 1, + signal: new AbortController().signal, + }); + expect(result!.status).toBe("ok"); + expect(result!.stepCount).toBe(8); + }); + + it("hands back at half the time limit too", async () => { + vi.useFakeTimers(); + try { + const { deps } = harness( + ({ options }) => + new Promise((resolve) => { + options.eventHook?.(stepStarted(0)); + options.eventHook?.(read()); + options.signal?.addEventListener("abort", () => + resolve(turnResult({ reason: "cancelled", stepCount: 1 })), + ); + }), + ); + const run = runWorkerTasks(deps, { + ...BASE, + workerTimeoutMs: 60_000, + tasks: [{ ...tasks(1)[0]!, files: ["a.js"] }], + maxWorkers: 1, + signal: new AbortController().signal, + }); + await vi.advanceTimersByTimeAsync(30_000); + const [result] = await run; + expect(result!.status).toBe("needs_orchestrator"); + expect(result!.reply).toContain("handed back early"); + expect(result!.reply).toContain("of 1 min"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/tools/fusion/worker-runner.ts b/src/tools/fusion/worker-runner.ts index 5d1da7fb..98b6aa2b 100644 --- a/src/tools/fusion/worker-runner.ts +++ b/src/tools/fusion/worker-runner.ts @@ -1,4 +1,5 @@ import type { AgentLoopEvent, RunTurnResult } from "../../agent/agent-loop.js"; +import type { ReasoningEffort } from "../../llm/provider/completion-types.js"; import type { ApprovalGate } from "../../approval/approval-gate.js"; import type { SessionState } from "../../session/session-state.js"; import type { FusionWorkerMeta } from "../../session/fusion-worker-session.js"; @@ -31,6 +32,70 @@ import type { ToolRole } from "../tool-roles.js"; */ export const WORKER_TOOL_LINES_PER_TASK = 5; +/** + * A local worker's time limit, sized from what it has to produce and how + * fast this machine produces it (D4 / F19). The estimate is the brief + * (≈ chars / 4 tokens) plus 2,000 tokens per declared output file, at + * the measured generation speed, times three for reads, reasoning and + * retries; clamped to [10 min, ceiling], where the ceiling is the + * configured `workerTimeoutMs` (45 min by default). Two local workers + * once ran the full 45 minutes, one in a read loop; a 4B model writing + * a module is done in far less, and a stuck one should end sooner. + * + * Without a measured speed (nothing has completed yet, or the leg is a + * cloud one) the ceiling is the limit, as before. + */ +export const WORKER_TIMEOUT_FLOOR_MS = 600_000; +export const WORKER_TOKENS_PER_DECLARED_FILE = 2_000; +export const WORKER_TIMEOUT_SAFETY_FACTOR = 3; + +export function estimateWorkerTimeoutMs(input: { + briefChars: number; + declaredFiles: number; + tokensPerSecond: number | null | undefined; + ceilingMs: number; +}): number { + const { tokensPerSecond, ceilingMs } = input; + if ( + tokensPerSecond === null || + tokensPerSecond === undefined || + !Number.isFinite(tokensPerSecond) || + tokensPerSecond <= 0 + ) { + return ceilingMs; + } + const tokens = + input.briefChars / 4 + + WORKER_TOKENS_PER_DECLARED_FILE * Math.max(0, input.declaredFiles); + const estimateMs = + (tokens / tokensPerSecond) * WORKER_TIMEOUT_SAFETY_FACTOR * 1000; + const floor = Math.min(WORKER_TIMEOUT_FLOOR_MS, ceilingMs); + return Math.round(Math.max(floor, Math.min(ceilingMs, estimateMs))); +} + +/** Tools whose success counts as "the worker wrote something". */ +const WRITE_TOOLS: ReadonlySet = new Set([ + "os.fs.write", + "os.fs.edit", + "os.fs.patch", +]); + +/** The forced summary a handed-back task replies with. */ +export function formatEarlyHandBack(input: { + stepsTaken: number; + stepBudget: number; + elapsedMs: number; + timeoutMs: number; + findings: string; +}): string { + const minutes = (ms: number): string => `${Math.round(ms / 60_000)} min`; + return ( + `handed back early: no file written by half the budget ` + + `(${input.stepsTaken} of ${input.stepBudget} steps, ${minutes(input.elapsedMs)} of ${minutes(input.timeoutMs)}); ` + + `what I found: ${input.findings}` + ); +} + export interface WorkerRunnerDeps { /** `runtime.runTurn`, unchanged. */ runTurn: ( @@ -43,6 +108,8 @@ export interface WorkerRunnerDeps { taskMaxDurationMs?: number; toolFilter?: (name: string) => boolean; toolRole?: ToolRole; + reasoningEffort?: ReasoningEffort; + maxOutputTokens?: number; signal?: AbortSignal; eventHook?: (event: AgentLoopEvent) => void; }, @@ -71,7 +138,22 @@ export interface RunWorkerTasksOptions { */ workerModel: string; workerMaxSteps: number; + /** + * Wall-clock ceiling per worker turn. With `localTokensPerSecond` it + * is the ceiling of a per-task estimate (`estimateWorkerTimeoutMs`); + * without, it is the limit itself. + */ workerTimeoutMs: number; + /** + * The local leg's measured generation speed + * (`LlamaServerClient.measuredTokensPerSecond`), `null` when nothing + * has completed yet. Absent for a cloud leg, which keeps the ceiling. + */ + localTokensPerSecond?: number | null; + /** `runMode.fusion.workerReasoning`, sent with every worker completion. */ + workerReasoning?: ReasoningEffort; + /** `runMode.fusion.workerMaxOutputTokens`, the per-step output cap. */ + workerMaxOutputTokens?: number; /** * Directories these workers may write in without asking, as approved * by the operator on this fan-out's own prompt. Empty means nothing @@ -226,48 +308,92 @@ async function runOneTask( deps.approvals.fanoutScopes?.grant(session.id, writeScope); } + const brief = renderWorkerBrief(task, { + workingDir: deps.workingDir, + ...(options.originalRequest === undefined + ? {} + : { originalRequest: options.originalRequest }), + ...(options.contract === undefined ? {} : { contract: options.contract }), + }); + const declaredFiles = task.files?.length ?? 0; + // Sized from the work and the machine when the leg is local and has + // been measured; the configured ceiling otherwise. + const timeoutMs = estimateWorkerTimeoutMs({ + briefChars: brief.length, + declaredFiles, + tokensPerSecond: options.localTokensPerSecond, + ceilingMs: options.workerTimeoutMs, + }); + // The worker's own clock, kept apart from the operator's signal: when // it is the one that fired, the worker ran out of time — a ceiling, // reported as `max_steps` — rather than being cancelled by anybody. - const timeLimit = AbortSignal.timeout(options.workerTimeoutMs); + const timeLimit = AbortSignal.timeout(timeoutMs); const hitTimeLimit = (): boolean => timeLimit.aborted && !options.signal.aborted; + // D4: a task that declared output files and has written none by half + // its step budget or half its time is handed back with what it found, + // instead of spending the other half the same way (two workers once + // used 40 steps each and wrote nothing). Only for tasks with declared + // files: a task that legitimately reads before it reports has no + // half-way mark to miss. + const handBack = new AbortController(); + const halfSteps = Math.max(1, Math.floor(options.workerMaxSteps / 2)); + let stepsStarted = 0; + let wroteSomething = false; + const maybeHandBack = (): void => { + if (declaredFiles === 0 || wroteSomething || handBack.signal.aborted) return; + handBack.abort(new Error("handed back early: no file written by half the budget")); + }; + const halfTimer = setTimeout(maybeHandBack, Math.floor(timeoutMs / 2)); + halfTimer.unref?.(); + const handedBack = (): boolean => + handBack.signal.aborted && !options.signal.aborted && !timeLimit.aborted; + let result: WorkerTaskResult; try { - const turn = await deps.runTurn( - session, - renderWorkerBrief(task, { - workingDir: deps.workingDir, - ...(options.originalRequest === undefined - ? {} - : { originalRequest: options.originalRequest }), - ...(options.contract === undefined ? {} : { contract: options.contract }), - }), - { - origin: "fusion", - providerId: options.providerId, - maxSteps: options.workerMaxSteps, - taskMaxDurationMs: options.workerTimeoutMs, - toolFilter: isWorkerVisibleTool, - toolRole: WORKER_TOOL_ROLE, - signal: AbortSignal.any([options.signal, timeLimit]), - eventHook: (event) => { - if (event.type === "turn_started") announceStart(); - if ( - event.type === "llm_event" && - event.event.type === "tool_call_parsed" - ) { - // `tool_call_parsed` fires before execution, which is what - // "is being triggered" means — and it fires once per call in - // a batched step, so the dedupe above earns its keep. - announceStart(); - announceTool(event.event.call.tool); - } - collector.observe(event); - }, + const turn = await deps.runTurn(session, brief, { + origin: "fusion", + providerId: options.providerId, + maxSteps: options.workerMaxSteps, + taskMaxDurationMs: timeoutMs, + toolFilter: isWorkerVisibleTool, + toolRole: WORKER_TOOL_ROLE, + ...(options.workerReasoning === undefined + ? {} + : { reasoningEffort: options.workerReasoning }), + ...(options.workerMaxOutputTokens === undefined + ? {} + : { maxOutputTokens: options.workerMaxOutputTokens }), + signal: AbortSignal.any([options.signal, timeLimit, handBack.signal]), + eventHook: (event) => { + if (event.type === "turn_started") announceStart(); + if (event.type === "step_started") { + stepsStarted += 1; + if (stepsStarted > halfSteps) maybeHandBack(); + } + if ( + event.type === "llm_event" && + event.event.type === "tool_call_parsed" + ) { + // `tool_call_parsed` fires before execution, which is what + // "is being triggered" means — and it fires once per call in + // a batched step, so the dedupe above earns its keep. + announceStart(); + announceTool(event.event.call.tool); + } + if ( + event.type === "llm_event" && + event.event.type === "tool_call_executed" && + event.event.result.status === "ok" && + WRITE_TOOLS.has(event.event.result.tool) + ) { + wroteSomething = true; + } + collector.observe(event); }, - ); + }); const timedOut = turn.reason === "cancelled" && hitTimeLimit(); const stopCause = timedOut ? "time_ceiling" : turn.stopCause; result = collector.finish({ @@ -287,7 +413,8 @@ async function runOneTask( } catch (error) { const timedOut = hitTimeLimit(); const aborted = - !timedOut && (options.signal.aborted || isAbortError(error)); + !timedOut && + (options.signal.aborted || handedBack() || isAbortError(error)); result = collector.finish({ id: task.id, title: task.title, @@ -302,12 +429,38 @@ async function runOneTask( : { error: error instanceof Error ? error.message : String(error) }), }); } finally { + clearTimeout(halfTimer); // Always: the gate is process-wide and a stale refusal policy keyed // to a dead session is a slow leak, not a visible bug. deps.approvals.clearSessionPolicy(session.id); deps.approvals.fanoutScopes?.clear(session.id); } + // A hand-back is neither a cancellation nor a failure: the worker was + // stopped by its own half-way rule and reports what it found, as a + // task the orchestrator must re-brief. + if (handedBack()) { + const { error: _dropped, ...rest } = result; + const summary = formatEarlyHandBack({ + // Steps completed when the loop reported them; the started count + // only when the turn threw before it could. + stepsTaken: result.stepCount > 0 ? result.stepCount : stepsStarted, + stepBudget: options.workerMaxSteps, + elapsedMs: result.durationMs, + timeoutMs, + findings: collector.findings(), + }); + result = { + ...rest, + status: "needs_orchestrator", + reply: summary, + notes: [ + ...(result.notes ?? []), + "handed back early: declared files but wrote none by half the budget — re-brief with a narrower task or the exact content to write", + ], + }; + } + // Ground truth before the orchestrator reads the reply: a worker that // says it wrote a file it never wrote must not come back `ok`, and one // that wrote nothing at all is `no_changes`. Only for statuses that diff --git a/src/tools/os/node-check-notice.test.ts b/src/tools/os/node-check-notice.test.ts new file mode 100644 index 00000000..e67638c8 --- /dev/null +++ b/src/tools/os/node-check-notice.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ApprovalGate } from "../../approval/approval-gate.js"; +import type { ToolContext } from "../tool-registry.js"; +import { buildOsShellTool } from "./shell.js"; +import { nodeCheckMultiFileNotice, nodeCheckPaths } from "./node-check-notice.js"; + +describe("node --check over several paths (F9a)", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "atomic-node-check-")); + await mkdir(join(dir, "js")); + for (const name of ["a.js", "b.js", "c.js"]) { + await writeFile(join(dir, "js", name), "const x = 1;\n"); + } + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reads the paths node would see, flags skipped", () => { + expect(nodeCheckPaths("node --check js/a.js js/b.js", dir)).toEqual([ + "js/a.js", + "js/b.js", + ]); + expect(nodeCheckPaths("node -c --no-warnings js/a.js", dir)).toEqual([ + "js/a.js", + ]); + expect(nodeCheckPaths("/usr/local/bin/node --check 'js/a.js'", dir)).toEqual([ + "js/a.js", + ]); + }); + + it("expands a glob the way the subshell would", () => { + // On the subshell path the tool hands `node --check js/*.js` to + // `sh -c` as written and never sees the argv; the notice has to + // count what the shell will produce. + expect(nodeCheckPaths("node --check js/*.js", dir)).toEqual([ + "js/a.js", + "js/b.js", + "js/c.js", + ]); + // A pattern matching nothing passes through verbatim, as in POSIX. + expect(nodeCheckPaths("node --check js/*.ts", dir)).toEqual(["js/*.ts"]); + }); + + it("finds the check behind a separator and ignores lines without one", () => { + expect(nodeCheckPaths("cd js && node --check a.js b.js", join(dir, "js"))).toEqual([ + "a.js", + "b.js", + ]); + expect(nodeCheckPaths("node js/a.js js/b.js", dir)).toBeNull(); + expect(nodeCheckPaths("node -e 'console.log(1)'", dir)).toBeNull(); + expect(nodeCheckPaths("python3 -c 'print(1)'", dir)).toBeNull(); + expect(nodeCheckPaths("ls", dir)).toBeNull(); + }); + + it("notices only when more than one path is checked", () => { + expect(nodeCheckMultiFileNotice("node --check js/a.js", dir)).toBeNull(); + expect(nodeCheckMultiFileNotice("ls js", dir)).toBeNull(); + const notice = nodeCheckMultiFileNotice("node --check js/*.js", dir); + expect(notice).toContain("node --check checks only the first file (js/a.js)"); + expect(notice).toContain("run one command per file"); + expect(notice).toContain("2 paths were not checked"); + }); + + it("prepends the notice to the shell tool's result, structured and subshell forms", async () => { + // Run 12 replied "ran node --check on all JavaScript files (all + // passed)" after exactly this command. Exit 0 says nothing; the + // result now does, before the `$ …` header so a capped result still + // shows it. + const gate = new ApprovalGate({ emit: () => undefined }); + const tool = buildOsShellTool({ approvals: gate, approvalRequired: false }); + const ctx: ToolContext = { + sessionId: "test-session", + workingDir: dir, + signal: new AbortController().signal, + }; + const structured = await tool.run( + { cmd: "node", args: ["--check", "js/a.js", "js/b.js"] }, + ctx, + ); + expect(structured.status).toBe("ok"); + expect(structured.summary.startsWith("node --check checks only the first file (js/a.js)")).toBe(true); + expect(structured.summary).toContain("$ node --check js/a.js js/b.js"); + + const subshell = await tool.run({ cmd: "node --check js/*.js" }, ctx); + expect(subshell.status).toBe("ok"); + expect(subshell.summary.startsWith("node --check checks only the first file (js/a.js)")).toBe(true); + + const single = await tool.run({ cmd: "node", args: ["--check", "js/a.js"] }, ctx); + expect(single.summary.startsWith("$ node --check js/a.js")).toBe(true); + }); +}); diff --git a/src/tools/os/node-check-notice.ts b/src/tools/os/node-check-notice.ts new file mode 100644 index 00000000..1aef5338 --- /dev/null +++ b/src/tools/os/node-check-notice.ts @@ -0,0 +1,103 @@ +import { globSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { basenameCommand } from "./shell-command-guard/normalise.js"; + +/** + * `node --check a.js b.js c.js` checks `a.js` and ignores the rest. + * + * That is node's own contract — `--check` takes one script, extra + * positionals are its `argv` — and it is how a run replied "ran + * node --check on all JavaScript files (all passed)" after one file was + * checked, and the next attempt reported "Syntax: all passed" from the + * same one-file check. Nothing in the output says so: exit 0, no text. + * + * So the shell tool says it. A command line that runs `node --check` / + * `node -c` over more than one path gets this line prepended to its + * result, naming the one file that was checked. Globs are expanded here + * the way the subshell expands them, because on the subshell path the + * tool never sees the expanded argv (`node --check js/*.js` is handed to + * `sh -c` as written). + */ + +/** Hard cap on glob matches counted, same order as the shell tool's own. */ +const MAX_GLOB_MATCHES = 10_000; + +/** Command-line separators; each segment is one command. */ +const SEGMENT_RE = /\s*(?:&&|\|\||;|\|)\s*/; + +function isNode(token: string): boolean { + const bin = basenameCommand(token) + .toLowerCase() + .replace(/\.exe$/, ""); + return bin === "node" || bin === "nodejs"; +} + +function isCheckFlag(token: string): boolean { + return token === "--check" || token === "-c"; +} + +/** Strip one layer of matching quotes — the shapes models emit. */ +function unquote(token: string): string { + if (token.length >= 2) { + const first = token[0]; + const last = token[token.length - 1]; + if ((first === '"' || first === "'") && first === last) { + return token.slice(1, -1); + } + } + return token; +} + +function expand(pattern: string, cwd: string): string[] { + if (!/[*?]/.test(pattern)) return [pattern]; + try { + const matches = globSync(pattern, { + cwd: isAbsolute(pattern) ? undefined : cwd, + }).slice(0, MAX_GLOB_MATCHES); + // A pattern that matches nothing passes through verbatim — what a + // POSIX shell does without `nullglob`, and what node then sees. + return matches.length > 0 ? matches.sort() : [pattern]; + } catch { + return [pattern]; + } +} + +/** + * The paths a `node --check` in `commandLine` would be given, in the + * order node sees them — or `null` when the line runs no such check. + * Only the first `node --check` segment is read: one notice per call. + */ +export function nodeCheckPaths( + commandLine: string, + cwd: string, +): string[] | null { + for (const segment of commandLine.split(SEGMENT_RE)) { + const tokens = segment + .split(/\s+/) + .filter((t) => t.length > 0) + .map(unquote); + if (tokens.length === 0 || !isNode(tokens[0]!)) continue; + const flagIdx = tokens.findIndex((t, i) => i > 0 && isCheckFlag(t)); + if (flagIdx === -1) continue; + const paths: string[] = []; + for (const token of tokens.slice(flagIdx + 1)) { + if (token.startsWith("-")) continue; + paths.push(...expand(token, cwd)); + } + return paths; + } + return null; +} + +/** + * The line prepended to an `os.shell.run` result whose command ran + * `node --check` over several paths; `null` when it did not. + */ +export function nodeCheckMultiFileNotice( + commandLine: string, + cwd: string, +): string | null { + const paths = nodeCheckPaths(commandLine, cwd); + if (paths === null || paths.length < 2) return null; + return `node --check checks only the first file (${paths[0]}); run one command per file — the other ${paths.length - 1} path${paths.length === 2 ? " was" : "s were"} not checked`; +} diff --git a/src/tools/os/shell.ts b/src/tools/os/shell.ts index 93e0ed21..0a03ac70 100644 --- a/src/tools/os/shell.ts +++ b/src/tools/os/shell.ts @@ -11,6 +11,7 @@ import { } from "../../approval/dangerous-tool.js"; import { resolveUserPath } from "./expand-home.js"; import { expandShellGlobArgs } from "./expand-shell-glob-args.js"; +import { nodeCheckMultiFileNotice } from "./node-check-notice.js"; import { basenameCommand, checkShellCommandGuard, @@ -344,11 +345,15 @@ export function buildOsShellTool(options: OsShellToolOptions): ToolDefinition { const body = [result.stdout, result.stderr] .filter((s) => s.trim().length > 0) .join("\n---\n"); + // `node --check a b c` exits 0 having read only `a`. Said here, + // first, because nothing in node's own output says it — and a + // reply built on that exit code claims a check that never ran. + const checkNotice = nodeCheckMultiFileNotice(commandLine, cwd); return compressToolResult( { tool: "os.shell.run", status, - output: `${header}\n${body}`, + output: `${checkNotice === null ? "" : `${checkNotice}\n`}${header}\n${body}`, details: { cmd, args: execArgs, diff --git a/src/tracing/trace/trace-event.ts b/src/tracing/trace/trace-event.ts index 7186f0dc..ee220897 100644 --- a/src/tracing/trace/trace-event.ts +++ b/src/tracing/trace/trace-event.ts @@ -189,6 +189,8 @@ export interface TraceBatchTrimmed extends TraceEventBase { kept: string; /** Tools that never ran, in emitted order. */ dropped: string[]; + /** Tools the turn's policy would have refused anyway; omitted when none. */ + refused?: string[]; reason: "approval-gated-batched"; } diff --git a/src/tracing/trace/trace-recorder.ts b/src/tracing/trace/trace-recorder.ts index 81dbadce..ee1b2676 100644 --- a/src/tracing/trace/trace-recorder.ts +++ b/src/tracing/trace/trace-recorder.ts @@ -251,6 +251,7 @@ export function createTraceRecorder( originalSize: inner.originalSize, kept: inner.kept, dropped: [...inner.dropped], + ...(inner.refused !== undefined ? { refused: [...inner.refused] } : {}), reason: inner.reason, }); return; diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index 3a930c01..dab2c088 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -989,7 +989,9 @@ function reduceStepEvent( return appendFeed(state, { kind: "runtime_info", stepIndex: event.stepIndex, - line: ` ~ batch trimmed to ${event.kept} (${event.dropped.length} of ${event.originalSize} deferred: ${event.reason})`, + line: + ` ~ batch trimmed to ${event.kept} (${event.dropped.length} of ${event.originalSize} deferred: ${event.reason}` + + `${event.refused && event.refused.length > 0 ? `; ${event.refused.length} refused by the turn policy` : ""})`, color: "yellow", }); case "batch_wave_split": diff --git a/src/tui/commands/run-mode-verb.ts b/src/tui/commands/run-mode-verb.ts index 629b3edb..5dff1866 100644 --- a/src/tui/commands/run-mode-verb.ts +++ b/src/tui/commands/run-mode-verb.ts @@ -1,4 +1,6 @@ +import { getConfig } from "../../config/index.js"; import { describeRunMode } from "../../llm/run-mode/index.js"; +import { resolveFusionMachineFacts } from "../../prompt/fusion-machine-facts.js"; import { activateComposerSwitchRow, backendSwitchRow, @@ -22,10 +24,13 @@ export function runRunModeVerb( ): void { if (verb === "status") { const rm = state.providersPanel.runMode; + // What will run, not what the default is: the facts the model is + // told, read from the config the way `buildPrompt` reads them. The + // live slot count is the runtime's; here only the configured one. dispatch({ type: "system_message", text: rm - ? describeRunMode(rm) + ? describeRunMode(rm, resolveFusionMachineFacts(getConfig())) : "run mode: not resolved yet — open Manage › LLM once", }); return;