diff --git a/docs/durable-execution.md b/docs/durable-execution.md index c82fa2ee5..860c4a628 100644 --- a/docs/durable-execution.md +++ b/docs/durable-execution.md @@ -29,7 +29,7 @@ A checkpoint is the run's whole transcript plus its step count, usage, | Right after each model response that asks for tools, before any tool runs | `'in-progress'` | | Each time a tool result is recorded (results are recorded in call order) | `'in-progress'` | | When the run is aborted (`signal`) | `'in-progress'` | -| When the run pauses for an approval | `'awaiting-approval'` (with `approvalId`) | +| When the run pauses for an approval (its own tool call's, or a [sub-agent](sub-agents.md)'s, also when a resumed sub-agent pauses again) | `'awaiting-approval'` (with `approvalId`) | | When the run finishes (the model stopped calling tools, or `maxSteps` ran out) | `'finished'` | A run that rejects (a provider error, a throwing hook, a diff --git a/llms-full.txt b/llms-full.txt index 2202587c9..e5174131f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2749,7 +2749,7 @@ A checkpoint is the run's whole transcript plus its step count, usage, | Right after each model response that asks for tools, before any tool runs | `'in-progress'` | | Each time a tool result is recorded (results are recorded in call order) | `'in-progress'` | | When the run is aborted (`signal`) | `'in-progress'` | -| When the run pauses for an approval | `'awaiting-approval'` (with `approvalId`) | +| When the run pauses for an approval (its own tool call's, or a [sub-agent](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md)'s, also when a resumed sub-agent pauses again) | `'awaiting-approval'` (with `approvalId`) | | When the run finishes (the model stopped calling tools, or `maxSteps` ran out) | `'finished'` | A run that rejects (a provider error, a throwing hook, a diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 49d02f9b7..bca6bfce3 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -132,6 +132,9 @@ export async function resumeAfterApproval( }; const step = await decidedToolMessage(ctx, pending); if ('paused' in step) { + // LOU-U8: the sub-agent paused again, so the session still awaits an approval. + const businessState = executeOptions.businessState !== undefined ? executeOptions.businessState : staleBusinessState; + await markAwaitingApproval(snapshot, step.paused, checkpointStore, businessState); return step.paused; } // LOU-Y1: a call whose sub-agent paused already has a placeholder result. @@ -226,6 +229,37 @@ async function clearStaleCheckpoint( return staleCheckpoint?.businessState; } +/** + * LOU-U8 + LOU-Y1: when a resumed sub-agent pauses again, no execute() call + * of this run writes a checkpoint (the stale one was just deleted), so mark + * the session 'awaiting-approval' here - a later execute() on it then throws + * SessionAwaitingApprovalError instead of starting over. Only the status, + * approval id and businessState are read back: the next resumeAfterApproval() + * rebuilds the run from its approval snapshot. + */ +async function markAwaitingApproval( + snapshot: ExecutionSnapshot, + paused: ExecutionResult, + checkpointStore: CheckpointStore | undefined, + businessState: unknown +): Promise { + if (!snapshot.sessionId || !checkpointStore) { + return; + } + await checkpointStore.save(snapshot.sessionId, { + agentId: snapshot.agent.id || '', + sessionId: snapshot.sessionId, + stepIndex: paused.steps, + messages: paused.messages, + toolCalls: [], + usage: structuredClone(paused.usage), + finishReason: paused.finishReason, + businessState, + status: 'awaiting-approval', + approvalId: paused.approvalId, + }); +} + /** * LOU-U7 backward compatibility: a snapshot saved before * `remainingToolCalls` existed records no remaining calls, so - as before - diff --git a/src/execution/subagentInheritance.test.ts b/src/execution/subagentInheritance.test.ts index edb75f329..10ec8f190 100644 --- a/src/execution/subagentInheritance.test.ts +++ b/src/execution/subagentInheritance.test.ts @@ -16,6 +16,8 @@ import { ToolRegistry } from '../tools/ToolRegistry'; import { AgentType } from '../types'; import { mockModel, type MockTurn } from '../testing'; import type { Message } from '../providers'; +import { KVCheckpointStore } from '../deploy/kvCheckpointStore'; +import { SessionAwaitingApprovalError } from './errors'; const lead = { name: 'lead', agentType: AgentType.SmartAssistant, prompt: 'You coordinate.' }; @@ -511,3 +513,127 @@ describe('approval inside a sub-agent (LOU-Y1)', () => { expect(taskResult.content).toContain('requires approval but no approvalStore'); }); }); + +describe('durable sessions with sub-agents (LOU-U7/U8 with LOU-Y1)', () => { + function checkpoints() { + const data = new Map(); + const store = new KVCheckpointStore({ + get: async (key) => data.get(key) ?? null, + put: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + }); + return store; + } + + it('a run paused on a sub-agent marks its session awaiting approval until resumed', async () => { + const approvals = memoryApprovals(); + const checkpointStore = checkpoints(); + const { agent: researcher, sent } = approvingChild('researcher', [{ toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, 'sent']); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done', 'next answer']); + const options = { subagents: { researcher } }; + const session = { sessionId: 's-1', checkpointStore }; + + const paused = await run({ provider: leadModel, ...options, ...session, approvalStore: approvals.store }); + + expect(await checkpointStore.load('s-1')).toMatchObject({ status: 'awaiting-approval', approvalId: paused.approvalId }); + await expect(run({ provider: leadModel, ...options, ...session, input: 'hello?' })).rejects.toBeInstanceOf( + SessionAwaitingApprovalError + ); + + const done = await resumeAfterApproval( + { id: paused.approvalId!, approved: true }, + approvals.store, + new ToolRegistry(), + leadModel, + options, + checkpointStore + ); + + expect(sent).toEqual(['ana']); + expect(done.text).toBe('done'); + expect(await checkpointStore.load('s-1')).toMatchObject({ status: 'finished' }); + const next = await run({ provider: leadModel, ...options, ...session, input: 'and now?' }); + expect(next.text).toBe('next answer'); + }); + + it('keeps the session awaiting approval when the resumed sub-agent pauses again', async () => { + const approvals = memoryApprovals(); + const checkpointStore = checkpoints(); + const { agent: researcher, sent } = approvingChild('researcher', [ + { toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, + { toolCalls: [{ name: 'send', args: { to: 'bo' } }] }, + 'both sent', + ]); + const leadModel = mockModel([{ toolCalls: [task('researcher')] }, 'done']); + const options = { subagents: { researcher } }; + const session = { sessionId: 's-2', checkpointStore }; + + const first = await run({ provider: leadModel, ...options, ...session, approvalStore: approvals.store }); + const resume = (id: string) => + resumeAfterApproval({ id, approved: true }, approvals.store, new ToolRegistry(), leadModel, options, checkpointStore); + const second = await resume(first.approvalId!); + + expect(second.finishReason).toBe('awaiting-approval'); + expect(await checkpointStore.load('s-2')).toMatchObject({ status: 'awaiting-approval', approvalId: second.approvalId }); + await expect(run({ provider: leadModel, ...options, ...session })).rejects.toBeInstanceOf(SessionAwaitingApprovalError); + + const done = await resume(second.approvalId!); + + expect(sent).toEqual(['ana', 'bo']); + expect(done.text).toBe('done'); + expect(await checkpointStore.load('s-2')).toMatchObject({ status: 'finished' }); + }); + + it('runs a task call left after an approval mid-batch, which can pause on its sub-agent in turn', async () => { + const approvals = memoryApprovals(); + const confirmed: string[] = []; + const confirm = defineTool({ + name: 'confirm', + description: 'Confirms', + input: z.object({}), + needsApproval: true, + execute: (_args, ctx) => { + confirmed.push(ctx.toolCallId); + return 'confirmed'; + }, + }); + const registry = new ToolRegistry(); + registry.registerMany([confirm]); + const { agent: researcher, model: childModel, sent } = approvingChild('researcher', [ + { toolCalls: [{ name: 'send', args: { to: 'ana' } }] }, + 'sent', + ]); + const leadModel = mockModel([{ toolCalls: [{ name: 'confirm', id: 'confirm-call' }, task('researcher')] }, 'done']); + const options = { subagents: { researcher } }; + const agent = { ...lead, tools: { confirm: { tool: 'confirm' } } }; + + const first = await AgentExecutor.execute({ + agent, + input: 'go', + provider: leadModel, + toolRegistry: registry, + ...options, + approvalStore: approvals.store, + }); + expect(approvals.only().snapshot.remainingToolCalls?.map((call) => call.id)).toEqual(['researcher-call']); + expect(childModel.calls).toHaveLength(0); + + const second = await resumeAfterApproval({ id: first.approvalId!, approved: true }, approvals.store, registry, leadModel, options); + + expect(confirmed).toEqual(['confirm-call']); + expect(second.finishReason).toBe('awaiting-approval'); + expect(approvals.only().pending).toMatchObject({ toolName: 'send', subagentPath: ['researcher'] }); + + const done = await resumeAfterApproval({ id: second.approvalId!, approved: true }, approvals.store, registry, leadModel, options); + + expect(sent).toEqual(['ana']); + expect(done.text).toBe('done'); + expect(leadModel.calls).toHaveLength(2); + expect(toolResults(done.messages).map((m) => m.toolCallId)).toEqual(['confirm-call', 'researcher-call']); + expect(toolResults(done.messages)[1].content).toContain('sent'); + }); +});