diff --git a/src/agent/agent-loop.test.ts b/src/agent/agent-loop.test.ts index 86d68c9a..65b85682 100644 --- a/src/agent/agent-loop.test.ts +++ b/src/agent/agent-loop.test.ts @@ -338,6 +338,57 @@ describe("AgentLoop end-to-end with mock LLM", () => { expect(result.session.lastError).toMatch(/max_steps_reached: 2 steps/); }); + it("reserves the final step for a terminal reply", async () => { + const registry = buildDefaultToolRegistry(); + registry.register({ + name: "noop", + description: "no-op", + readonly: true, + async run() { + return { + tool: "noop", + status: "ok", + summary: "verified", + details: {}, + truncated: false, + }; + }, + }); + let calls = 0; + const prompts: string[] = []; + const loop = new AgentLoop({ + registry, + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: async (params) => { + calls += 1; + prompts.push(params.prompt); + return makeCompletion( + calls === 1 + ? JSON.stringify({ tool: "noop", args: {} }) + : JSON.stringify({ tool: "reply", args: { text: "verified" } }), + ); + }, + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "chat-finalize", workingDir }), + { userMessage: "verify", maxSteps: 2, signal: new AbortController().signal }, + ); + + expect(calls).toBe(2); + expect(prompts[1]).toContain("final allowed step"); + expect(result.reason).toBe("reply"); + expect(result.stepCount).toBe(2); + expect(result.session.status).toBe("pending"); + expect(result.session.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "verified", + }); + }); + it("injects a transient notice into the next prompt when a no-progress loop is detected", async () => { const registry = buildDefaultToolRegistry(); registry.register({ diff --git a/src/agent/agent-loop.ts b/src/agent/agent-loop.ts index b8036043..0fc46995 100644 --- a/src/agent/agent-loop.ts +++ b/src/agent/agent-loop.ts @@ -565,6 +565,10 @@ export class AgentLoop { } const noticeForThisStep = pendingNotice; pendingNotice = undefined; + const finalizationStep = i === options.maxSteps - 1; + const finalizationNotice = + "This is the final allowed step. Do not call any non-terminal tool; " + + "summarize the completed work with reply, or end the session with finish."; try { const profileFacts = this.deps.profileFactsProvider?.(); const activeProfile = @@ -576,14 +580,26 @@ export class AgentLoop { const outcome = await executeStep( { session: state, - toolDescriptors: this.deps.toolDescriptors, + toolDescriptors: finalizationStep + ? this.deps.toolDescriptors.filter( + ({ name }) => name === "reply" || name === "finish", + ) + : this.deps.toolDescriptors, capabilities: this.deps.capabilities, skillCatalog: this.deps.skillCatalog, stepIndex: i, signal: options.signal, - ...(noticeForThisStep !== undefined - ? { transientNotice: noticeForThisStep } + ...(finalizationStep || noticeForThisStep !== undefined + ? { + transientNotice: [ + noticeForThisStep, + ...(finalizationStep ? [finalizationNotice] : []), + ] + .filter((notice): notice is string => notice !== undefined) + .join("\n\n"), + } : {}), + ...(finalizationStep ? { terminalOnly: true } : {}), ...(profileFacts !== undefined ? { profileFacts } : {}), ...(options.userMessage !== undefined ? { userMessage: options.userMessage } @@ -796,6 +812,14 @@ export class AgentLoop { recordSurfacedLessons(state); recordSurfacedProcedures(state); } catch (err) { + if (finalizationStep) { + // A failed finalization must not execute more work or turn a + // bounded run into an unbounded retry. Preserve the established + // explicit max-steps/stalled outcome instead. + stepsTaken += 1; + reason = "max_steps"; + break; + } runError = err instanceof Error ? err : new Error(String(err)); const category = classifyFailure(err); this.deps.logger?.error("agent loop failed", { diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 98636f3d..b3d307a9 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -216,6 +216,8 @@ export interface StepContext { * continuation) — contextual facts stay suppressed. */ userMessage?: string | null; + /** Restrict this step to the terminal reply/finish tools. */ + terminalOnly?: boolean; } /** @@ -329,9 +331,14 @@ async function executeStepInner( ctx: StepContext, deps: StepDependencies, ): Promise { + const stepToolDescriptors = ctx.terminalOnly + ? ctx.toolDescriptors.filter( + ({ name }) => name === "reply" || name === "finish", + ) + : ctx.toolDescriptors; const prompt = buildPrompt({ session: ctx.session, - toolDescriptors: ctx.toolDescriptors, + toolDescriptors: stepToolDescriptors, capabilities: ctx.capabilities, skillCatalog: ctx.skillCatalog, currentDate: formatCurrentDate(new Date()), @@ -393,7 +400,7 @@ async function executeStepInner( deps, slotId: slot.slotId, sessionId: ctx.session.id, - toolDescriptors: ctx.toolDescriptors, + toolDescriptors: stepToolDescriptors, signal: ctx.signal, }); @@ -599,6 +606,22 @@ async function executeStepInner( deps.profile, parseDepsFor(completion, deps), ); + if (ctx.terminalOnly && parsed.ok) { + const nonTerminal = parsed.batch.calls.find( + ({ tool }) => tool !== "reply" && tool !== "finish", + ); + if (nonTerminal) { + parsed = { + ok: false, + error: new BatchValidationError( + "finalization step only accepts reply or finish", + [ + `non-terminal tool is not allowed at the step budget: ${nonTerminal.tool}`, + ], + ), + }; + } + } if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); if (!validation.ok) { @@ -725,6 +748,22 @@ async function executeStepInner( deps.profile, parseDepsFor(completion, deps), ); + if (ctx.terminalOnly && parsed.ok) { + const nonTerminal = parsed.batch.calls.find( + ({ tool }) => tool !== "reply" && tool !== "finish", + ); + if (nonTerminal) { + parsed = { + ok: false, + error: new BatchValidationError( + "finalization step only accepts reply or finish", + [ + `non-terminal tool is not allowed at the step budget: ${nonTerminal.tool}`, + ], + ), + }; + } + } if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); if (!validation.ok) {