Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/durable-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/execution/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
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 -
Expand Down
126 changes: 126 additions & 0 deletions src/execution/subagentInheritance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' };

Expand Down Expand Up @@ -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<string, string>();
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');
});
});
Loading