From 66066385f2606a930787f26be93f8c215d6719eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:33:28 +0000 Subject: [PATCH] LOU-D21: createAgent agents can pause for approval and resume A needsApproval tool no longer fails a createAgent() run with "requires approval but no approvalStore". Each agent now runs with an approval store (a new InMemoryApprovalStore per agent, or the `approvalStore` option), so the run pauses with finishReason 'awaiting-approval' and an approvalId. - agent.approvals.list() returns the calls the agent is paused on; agent.approvals.resolve({ id, approved, note? }) calls resumeAfterApproval() with the agent's store, registry, provider and options and returns the continued run's result. A pause inside agent.session() continues in that session (AgentSession.continueTurn). - `approve` option: decides each pause at once by resuming with the callback's answer (send(), sessions and approvals.resolve()). - Executor, resume and ApprovalGate are untouched. - serveMcp test updated: a createAgent() agent now pauses (and MCP reports the pause) instead of throwing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- CHANGELOG.md | 3 + README.md | 44 ++++++- docs/api-overview.md | 13 ++ docs/sub-agents.md | 9 +- llms-full.txt | 66 ++++++++-- src/createAgent.ts | 57 ++++++++- src/createAgentApprovals.test.ts | 165 +++++++++++++++++++++++++ src/createAgentApprovals.ts | 110 +++++++++++++++++ src/execution/InMemoryApprovalStore.ts | 33 +++++ src/execution/index.ts | 1 + src/index.ts | 1 + src/session/AgentSession.ts | 17 ++- src/tools/mcp/server/serveMcp.test.ts | 4 +- 13 files changed, 500 insertions(+), 23 deletions(-) create mode 100644 src/createAgentApprovals.test.ts create mode 100644 src/createAgentApprovals.ts create mode 100644 src/execution/InMemoryApprovalStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58cc7dd5a..4eea8287e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 +### Added +- `createAgent()` agents can pause for approval (LOU-D21): a `needsApproval` tool no longer fails the run with "requires approval but no approvalStore". The run pauses (`finishReason: 'awaiting-approval'`) in a per-agent `InMemoryApprovalStore` (or the new `approvalStore` option), and `agent.approvals.list()` / `agent.approvals.resolve({ id, approved, note? })` continue it, in its session if it paused in one. The `approve` option decides calls in code without pausing. + ### Changed - BREAKING: `encrypt()` now generates a random salt per call instead of a hardcoded one. Ciphertext produced before this change cannot be decrypted with the new code and must be re-encrypted. - BREAKING: The mock repositories (`MockAgentRepository`, `MockSessionRepository`, `MockResultRepository`, `MockMemoryRepository`, `MockAttachmentRepository` — previously re-exported from `src/data/mocks.ts` via the package root/`./data` subpath; this package has no `createMockRepositories` factory) are no longer exported from the package root. Import them from `@loushy/build-ai-agent/testing` instead. diff --git a/README.md b/README.md index 039ced5ac..9ac2d984a 100644 --- a/README.md +++ b/README.md @@ -373,9 +373,43 @@ fallback model. ### Human-in-the-loop approval gates -Flag a tool `needsApproval`; `AgentExecutor` pauses before calling it and -persists an `ExecutionSnapshot` instead of invoking the tool. Resume later — -after a real restart if you like — with `resumeAfterApproval()`: +Flag a tool `needsApproval` and the run pauses before calling it. A +`createAgent()` agent saves the pause in its own `InMemoryApprovalStore` +(or the `approvalStore` you pass): `send()` resolves with +`finishReason: 'awaiting-approval'` and an `approvalId`, +`agent.approvals.list()` shows what is waiting, and +`agent.approvals.resolve({ id, approved, note? })` runs (or rejects) the call +and continues the run — in the same session, if it paused inside +`agent.session()`. Pass `approve` to decide each call in code instead of +pausing (`stream()` still ends at the pause): + +```typescript +import { createAgent, defineTool } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const sendEmail = defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string() }), + needsApproval: true, + execute: async ({ to }) => `sent to ${to}`, +}); +const agent = createAgent({ provider, instructions: 'You send emails.', tools: [sendEmail] }); + +const paused = await agent.send('Email the report to sam@example.com'); +if (paused.finishReason === 'awaiting-approval') { + console.log(await agent.approvals.list()); // [{ id, toolName: 'send_email', args: { to: '...' }, ... }] + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); // or approved: false, note: 'why' + console.log(result.text); +} + +// Or decide in code, with no pause: true runs the tool, false sends the model a rejection. +const trusted = createAgent({ provider, tools: [sendEmail], approve: ({ args }) => String(args.to).endsWith('@example.com') }); +``` + +With `AgentExecutor` directly, pass an `approvalStore`: it persists an +`ExecutionSnapshot` instead of invoking the tool. Resume later — after a real +restart if you like — with `resumeAfterApproval()`: ```typescript import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore } from '@loushy/build-ai-agent'; @@ -665,7 +699,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off ### Core -- **`createAgent()`** - zero-config `{ send, stream }` agent +- **`createAgent()`** - zero-config `{ send, stream, session, approvals }` agent - **`AgentBuilder`** - fluent `AgentConfig` builder - **`AgentExecutor`** - static executor (`execute()`, approvals, checkpoints, tracing) - **`ToolRegistry`** - manage available tools @@ -674,7 +708,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off ### Safety & ops -- **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`** - human-in-the-loop +- **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`**, **`InMemoryApprovalStore`** - human-in-the-loop - **`LocalStorageCheckpointStore`** - durable execution - **`createDelegateTool()`** - delegate to one child agent through a tool (see `subagents` for named sub-agents) - **`runGuardrails()`**, **`secretScanGuardrail`**, **`createDiffSizeGuardrail()`**, **`createCommandGuardrail()`** - guardrails diff --git a/docs/api-overview.md b/docs/api-overview.md index a7b4272b2..5b3e67cf2 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -17,6 +17,7 @@ npm run docs:build # writes docs/api/index.html | `AgentExecutor.execute(opts)` | Static executor: runs an agent (LLM + tool-calling loop) and resolves to an `ExecutionResult`. | | `AgentType` | Agent type enum (e.g. `AgentType.SmartAssistant`). | | `resumeAfterApproval()` | Resume an execution paused for human approval. | +| `InMemoryApprovalStore` | Process-local `ApprovalStore`; the default store of `createAgent()` agents. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | ### Sub-agents @@ -29,6 +30,18 @@ signal, hooks (`ctx.subagent`), tracing, approval store and `onEvent` (`event.subagent`). `maxSubagentDepth` (default 1) bounds nesting. See [Sub-agents](./sub-agents.md). +### Approvals + +A `createAgent()` agent pauses on a `needsApproval` tool instead of failing: +`send()` (and `session.send()`) resolves with `finishReason: 'awaiting-approval'` +and an `approvalId`. `agent.approvals.list()` returns the pending calls and +`agent.approvals.resolve({ id, approved, note? })` runs or rejects the call and +resolves with the continued run's result (continuing the session it paused +in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass +`approvalStore` (e.g. `SqliteStore.approvals`); `approve: (call) => boolean` +decides each call in code without pausing (`stream()` still ends at the +pause). See [Human-in-the-loop approval gates](../README.md#human-in-the-loop-approval-gates). + ### Skills Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to diff --git a/docs/sub-agents.md b/docs/sub-agents.md index 45e990c9d..c69d05718 100644 --- a/docs/sub-agents.md +++ b/docs/sub-agents.md @@ -43,8 +43,9 @@ const { text } = await lead.send('Write a short article about the history of the ``` `AgentExecutor.execute()` takes the same `subagents` and `maxSubagentDepth` -options. Use it when you need hooks, tracing or approvals on the lead run - -sub-agents inherit those (see [Inheritance](#what-a-sub-agent-inherits)). +options. Use it when you need hooks or tracing on the lead run - sub-agents +inherit those (see [Inheritance](#what-a-sub-agent-inherits)). Approvals work +from `createAgent()` too (see [Approvals](#approvals-inside-a-sub-agent)). ## How it works @@ -207,6 +208,10 @@ again with a new `approvalId`. This works at any depth and for `createDelegateTool()` children (pass the registry that holds the delegate tool). +With a `createAgent()` lead, `lead.send()` pauses the same way and +`lead.approvals.resolve({ id, approved })` resumes it (the lead already knows +its `subagents`); the lead's `approve` option decides its sub-agents' calls too. + Guarantees and limits: - No call runs twice and none is lost: the sub-agent's paused state is stored diff --git a/llms-full.txt b/llms-full.txt index 88d5d9d41..c7ef880f4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -371,9 +371,43 @@ fallback model. ### Human-in-the-loop approval gates -Flag a tool `needsApproval`; `AgentExecutor` pauses before calling it and -persists an `ExecutionSnapshot` instead of invoking the tool. Resume later — -after a real restart if you like — with `resumeAfterApproval()`: +Flag a tool `needsApproval` and the run pauses before calling it. A +`createAgent()` agent saves the pause in its own `InMemoryApprovalStore` +(or the `approvalStore` you pass): `send()` resolves with +`finishReason: 'awaiting-approval'` and an `approvalId`, +`agent.approvals.list()` shows what is waiting, and +`agent.approvals.resolve({ id, approved, note? })` runs (or rejects) the call +and continues the run — in the same session, if it paused inside +`agent.session()`. Pass `approve` to decide each call in code instead of +pausing (`stream()` still ends at the pause): + +```typescript +import { createAgent, defineTool } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const sendEmail = defineTool({ + name: 'send_email', + description: 'Send an email', + input: z.object({ to: z.string() }), + needsApproval: true, + execute: async ({ to }) => `sent to ${to}`, +}); +const agent = createAgent({ provider, instructions: 'You send emails.', tools: [sendEmail] }); + +const paused = await agent.send('Email the report to sam@example.com'); +if (paused.finishReason === 'awaiting-approval') { + console.log(await agent.approvals.list()); // [{ id, toolName: 'send_email', args: { to: '...' }, ... }] + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); // or approved: false, note: 'why' + console.log(result.text); +} + +// Or decide in code, with no pause: true runs the tool, false sends the model a rejection. +const trusted = createAgent({ provider, tools: [sendEmail], approve: ({ args }) => String(args.to).endsWith('@example.com') }); +``` + +With `AgentExecutor` directly, pass an `approvalStore`: it persists an +`ExecutionSnapshot` instead of invoking the tool. Resume later — after a real +restart if you like — with `resumeAfterApproval()`: ```typescript import { AgentExecutor, resumeAfterApproval, StorageServiceApprovalStore } from '@loushy/build-ai-agent'; @@ -663,7 +697,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off ### Core -- **`createAgent()`** - zero-config `{ send, stream }` agent +- **`createAgent()`** - zero-config `{ send, stream, session, approvals }` agent - **`AgentBuilder`** - fluent `AgentConfig` builder - **`AgentExecutor`** - static executor (`execute()`, approvals, checkpoints, tracing) - **`ToolRegistry`** - manage available tools @@ -672,7 +706,7 @@ npm run pipeline:demo:trigger # POSTs a synthetic error to kick it off ### Safety & ops -- **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`** - human-in-the-loop +- **`resumeAfterApproval()`**, **`StorageServiceApprovalStore`**, **`InMemoryApprovalStore`** - human-in-the-loop - **`LocalStorageCheckpointStore`** - durable execution - **`createDelegateTool()`** - delegate to one child agent through a tool (see `subagents` for named sub-agents) - **`runGuardrails()`**, **`secretScanGuardrail`**, **`createDiffSizeGuardrail()`**, **`createCommandGuardrail()`** - guardrails @@ -1181,6 +1215,7 @@ npm run docs:build # writes docs/api/index.html | `AgentExecutor.execute(opts)` | Static executor: runs an agent (LLM + tool-calling loop) and resolves to an `ExecutionResult`. | | `AgentType` | Agent type enum (e.g. `AgentType.SmartAssistant`). | | `resumeAfterApproval()` | Resume an execution paused for human approval. | +| `InMemoryApprovalStore` | Process-local `ApprovalStore`; the default store of `createAgent()` agents. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | ### Sub-agents @@ -1193,6 +1228,18 @@ signal, hooks (`ctx.subagent`), tracing, approval store and `onEvent` (`event.subagent`). `maxSubagentDepth` (default 1) bounds nesting. See [Sub-agents](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sub-agents.md). +### Approvals + +A `createAgent()` agent pauses on a `needsApproval` tool instead of failing: +`send()` (and `session.send()`) resolves with `finishReason: 'awaiting-approval'` +and an `approvalId`. `agent.approvals.list()` returns the pending calls and +`agent.approvals.resolve({ id, approved, note? })` runs or rejects the call and +resolves with the continued run's result (continuing the session it paused +in). Pauses are kept in a per-agent `InMemoryApprovalStore` unless you pass +`approvalStore` (e.g. `SqliteStore.approvals`); `approve: (call) => boolean` +decides each call in code without pausing (`stream()` still ends at the +pause). See [Human-in-the-loop approval gates](https://github.com/LinuxDevil/agent-sdk/blob/main/README.md#human-in-the-loop-approval-gates). + ### Skills Pass `skills: [defineSkill({ name, description, content }), ...(await loadSkills(dir))]` to @@ -3586,8 +3633,9 @@ const { text } = await lead.send('Write a short article about the history of the ``` `AgentExecutor.execute()` takes the same `subagents` and `maxSubagentDepth` -options. Use it when you need hooks, tracing or approvals on the lead run - -sub-agents inherit those (see [Inheritance](#what-a-sub-agent-inherits)). +options. Use it when you need hooks or tracing on the lead run - sub-agents +inherit those (see [Inheritance](#what-a-sub-agent-inherits)). Approvals work +from `createAgent()` too (see [Approvals](#approvals-inside-a-sub-agent)). ## How it works @@ -3750,6 +3798,10 @@ again with a new `approvalId`. This works at any depth and for `createDelegateTool()` children (pass the registry that holds the delegate tool). +With a `createAgent()` lead, `lead.send()` pauses the same way and +`lead.approvals.resolve({ id, approved })` resumes it (the lead already knows +its `subagents`); the lead's `approve` option decides its sub-agents' calls too. + Guarantees and limits: - No call runs twice and none is lost: the sub-agent's paused state is stored diff --git a/src/createAgent.ts b/src/createAgent.ts index 66e97f5f4..82d218392 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -33,6 +33,10 @@ import { basename } from 'node:path'; import type { Subagents } from './subagents/types'; import { assertMaxSubagentDepth, assertNoTaskTool, assertSubagents, registerSubagent } from './subagents/withSubagents'; import type { SubagentSpec } from './execution/delegation'; +import type { ApprovalStore } from './execution/ApprovalGate'; +import { InMemoryApprovalStore } from './execution/InMemoryApprovalStore'; +import { resumeAfterApproval } from './execution/resume'; +import { createAgentApprovals, type AgentApprovals, type ApproveToolCall } from './createAgentApprovals'; /** * Options for createAgent() that do not depend on how the instructions and @@ -119,6 +123,24 @@ export interface CreateAgentBase { * ``` */ projectInstructions?: boolean | { cwd?: string; files?: readonly string[] }; + /** + * Where a run that hits a `needsApproval` tool saves its pause (LOU-D21). + * Defaults to a new `InMemoryApprovalStore` per agent; pass a durable store + * (e.g. `SqliteStore.approvals`) to resolve after a restart. Decide pauses + * with `agent.approvals.resolve()`. + */ + approvalStore?: ApprovalStore; + /** + * Decides approvals as they come up instead of pausing (LOU-D21): `true` + * runs the tool, `false` gives the model a rejection. Applies to `send()`, + * sessions and `agent.approvals.resolve()`; `stream()` still ends at the pause. + * + * @example + * ```ts + * createAgent({ prompt: '...', provider, tools: [sendEmail], approve: ({ args }) => args.to === 'me@example.com' }); + * ``` + */ + approve?: ApproveToolCall; } /** @@ -229,6 +251,19 @@ export interface SimpleAgent { * ``` */ session: (options?: SessionOptions) => AgentSession; + /** + * Tool calls this agent is paused on, waiting for approval (LOU-D21). A + * paused `send()` resolves with `finishReason: 'awaiting-approval'` and an + * `approvalId`; `resolve()` runs or rejects the call and continues the run + * (in its session, if it paused in one). + * + * @example + * ```ts + * const paused = await agent.send('Email the report to Sam'); + * const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + * ``` + */ + approvals: AgentApprovals; } /** @@ -264,18 +299,27 @@ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { const agent = builder.build(); if (config.subagents) assertNoTaskTool(agent, toolRegistry); - const spec: SubagentSpec = { - agent, - provider, - toolRegistry, + const runOptions = { skills: config.skills, subagents: config.subagents, maxSubagentDepth: config.maxSubagentDepth, maxSteps: config.maxSteps, toolConcurrency: config.toolConcurrency, }; + const spec: SubagentSpec = { agent, provider, toolRegistry, ...runOptions }; + const approvals = createAgentApprovals({ + store: config.approvalStore ?? new InMemoryApprovalStore(), + approve: config.approve, + resume: (approvalStore, decision, signal) => + resumeAfterApproval(decision, approvalStore, toolRegistry ?? new ToolRegistry(), provider, { + ...runOptions, + approvalStore, + signal, + }), + }); const executeOptions = (input: string | Message[], signal?: AbortSignal): ExecuteOptions => ({ ...spec, + approvalStore: approvals.store, input, signal, }); @@ -284,12 +328,13 @@ export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { const simpleAgent: SimpleAgent = { async send(message: string, options: SendOptions = {}): Promise { - return run(message, options.signal); + return approvals.settle(await run(message, options.signal), options.signal); }, stream(message: string, options: SendOptions = {}): AgentRun { return AgentExecutor.stream(executeOptions(message, options.signal)); }, - session: (options?: SessionOptions) => new AgentSession(run, options), + session: (options?: SessionOptions) => approvals.session(run, options), + approvals: approvals.approvals, }; registerSubagent(simpleAgent, { spec, description: config.description }); return simpleAgent; diff --git a/src/createAgentApprovals.test.ts b/src/createAgentApprovals.test.ts new file mode 100644 index 000000000..b7716c41e --- /dev/null +++ b/src/createAgentApprovals.test.ts @@ -0,0 +1,165 @@ +/** + * LOU-D21: createAgent() agents pause for approval (in-memory store by + * default) and resume with `agent.approvals.resolve()` or an `approve` callback. + */ +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import { createAgent } from './createAgent'; +import { defineTool } from './tools/defineTool'; +import { InMemoryApprovalStore } from './execution/InMemoryApprovalStore'; +import { mockModel } from './testing'; +import type { Message } from './providers'; + +function emailTool() { + const execute = vi.fn(async ({ to }: { to: string }) => `sent to ${to}`); + const tool = defineTool({ + name: 'send_email', + description: 'Sends an email', + input: z.object({ to: z.string() }), + needsApproval: true, + execute, + }); + return { tool, execute }; +} + +const callEmail = { toolCalls: [{ name: 'send_email', args: { to: 'sam@example.com' }, id: 'call_email' }] }; + +function toolResult(messages: readonly Message[]): Message | undefined { + return messages.find((m) => m.role === 'tool' && m.toolCallId === 'call_email'); +} + +describe('createAgent approvals (LOU-D21)', () => { + it('pauses on a needsApproval tool, then approving runs the tool and finishes', async () => { + const { tool, execute } = emailTool(); + const agent = createAgent({ provider: mockModel([callEmail, 'Email sent.']), tools: [tool] }); + + const paused = await agent.send('Email Sam'); + + expect(paused.finishReason).toBe('awaiting-approval'); + expect(paused.approvalId).toBeDefined(); + expect(execute).not.toHaveBeenCalled(); + const [pending] = await agent.approvals.list(); + expect(pending).toMatchObject({ id: paused.approvalId, toolName: 'send_email', args: { to: 'sam@example.com' } }); + + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('stop'); + expect(result.text).toBe('Email sent.'); + expect(toolResult(result.messages)?.content).toBe(JSON.stringify('sent to sam@example.com')); + expect(await agent.approvals.list()).toEqual([]); + await expect(agent.approvals.resolve({ id: paused.approvalId!, approved: true })).rejects.toThrow(/No pending approval/); + }); + + it('rejecting gives the model a structured rejection and does not run the tool', async () => { + const { tool, execute } = emailTool(); + const model = mockModel([callEmail, 'OK, I will not send it.']); + const agent = createAgent({ provider: model, tools: [tool] }); + + const paused = await agent.send('Email Sam'); + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: false, note: 'Not today' }); + + expect(execute).not.toHaveBeenCalled(); + expect(result.text).toBe('OK, I will not send it.'); + const rejection = toolResult(model.calls[1].messages as Message[]); + expect(rejection?.isError).toBe(true); + expect(JSON.parse(rejection?.content as string)).toEqual({ + error: 'Tool execution was rejected by the reviewer', + note: 'Not today', + }); + }); + + it('an approve callback decides at once, without pausing', async () => { + const approved = emailTool(); + const approve = vi.fn(() => true); + const yes = createAgent({ provider: mockModel([callEmail, 'Sent.']), tools: [approved.tool], approve }); + + const result = await yes.send('Email Sam'); + + expect(result.finishReason).toBe('stop'); + expect(result.text).toBe('Sent.'); + expect(approved.execute).toHaveBeenCalledTimes(1); + expect(approve).toHaveBeenCalledWith( + expect.objectContaining({ toolName: 'send_email', toolCallId: 'call_email', args: { to: 'sam@example.com' } }) + ); + + const denied = emailTool(); + const no = createAgent({ provider: mockModel([callEmail, 'Not sent.']), tools: [denied.tool], approve: async () => false }); + + const rejected = await no.send('Email Sam'); + + expect(rejected.finishReason).toBe('stop'); + expect(denied.execute).not.toHaveBeenCalled(); + expect(toolResult(rejected.messages)?.isError).toBe(true); + expect(await no.approvals.list()).toEqual([]); + }); + + it('gives each agent its own default store', async () => { + const first = createAgent({ provider: mockModel([callEmail]), tools: [emailTool().tool] }); + const second = createAgent({ provider: mockModel([callEmail]), tools: [emailTool().tool] }); + + const paused = await first.send('Email Sam'); + + expect(await second.approvals.list()).toEqual([]); + await expect(second.approvals.resolve({ id: paused.approvalId!, approved: true })).rejects.toThrow(/No pending approval/); + expect(await first.approvals.list()).toHaveLength(1); + }); + + it('uses the approvalStore option when given', async () => { + const approvalStore = new InMemoryApprovalStore(); + const save = vi.spyOn(approvalStore, 'save'); + const agent = createAgent({ provider: mockModel([callEmail, 'Done.']), tools: [emailTool().tool], approvalStore }); + + const paused = await agent.send('Email Sam'); + + expect(save).toHaveBeenCalledTimes(1); + expect(await approvalStore.resolve(paused.approvalId!)).toMatchObject({ pending: { toolName: 'send_email' } }); + }); + + it('resolving a pause from a session continues that session', async () => { + const { tool } = emailTool(); + const model = mockModel([callEmail, 'Email sent.', 'You asked me to email Sam.']); + const agent = createAgent({ provider: model, tools: [tool] }); + const session = agent.session(); + + const paused = await session.send('Email Sam'); + expect(paused.finishReason).toBe('awaiting-approval'); + await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + + expect(session.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']); + const followUp = await session.send('What did I ask?'); + expect(followUp.text).toBe('You asked me to email Sam.'); + const sent = model.calls[2].messages.map((m) => m.content); + expect(sent).toContain('Email Sam'); + expect(sent).toContain('Email sent.'); + expect(sent).toContain(JSON.stringify('sent to sam@example.com')); + }); + + it("pauses on a sub-agent's tool and resumes it through the lead's approvals", async () => { + const { tool, execute } = emailTool(); + const mailer = createAgent({ provider: mockModel([callEmail, 'Mailed.']), tools: [tool], description: 'Sends mail' }); + const task = { name: 'task', args: { agent: 'mailer', prompt: 'Email Sam', description: 'mail' } }; + const lead = createAgent({ provider: mockModel([{ toolCalls: [task] }, 'All done.']), subagents: { mailer } }); + + const paused = await lead.send('Email Sam via the mailer'); + const [pending] = await lead.approvals.list(); + expect(pending).toMatchObject({ toolName: 'send_email', subagentPath: ['mailer'] }); + + const result = await lead.approvals.resolve({ id: paused.approvalId!, approved: true }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(result.text).toBe('All done.'); + }); + + it('stream() ends at the pause instead of throwing', async () => { + const agent = createAgent({ provider: mockModel([callEmail, 'Sent.']), tools: [emailTool().tool] }); + + const run = agent.stream('Email Sam'); + for await (const event of run) void event; + const paused = await run.result; + + expect(paused.finishReason).toBe('awaiting-approval'); + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + expect(result.text).toBe('Sent.'); + }); +}); diff --git a/src/createAgentApprovals.ts b/src/createAgentApprovals.ts new file mode 100644 index 000000000..250e23717 --- /dev/null +++ b/src/createAgentApprovals.ts @@ -0,0 +1,110 @@ +/** + * Approvals for createAgent() agents (LOU-D21): a thin layer over an + * ApprovalStore and resumeAfterApproval(). It records which approvals the + * agent paused on (for `agent.approvals.list()`), continues a paused session + * when its approval is resolved, and - with an `approve` callback - decides + * each pause at once by resuming the run with the callback's answer. + */ + +import type { ApprovalDecision, ApprovalStore, PendingApproval } from './execution/ApprovalGate'; +import type { ExecutionResult } from './execution/AgentExecutor'; +import { AgentSession, type SessionOptions, type SessionRunner } from './session/AgentSession'; + +/** + * Decides a tool call that needs approval without pausing the run: `true` + * runs the tool, `false` gives the model a rejection as the tool's result. + * + * @example + * ```ts + * const approve: ApproveToolCall = ({ toolName, args }) => toolName !== 'send_email' || args.to === 'me@example.com'; + * ``` + */ +export type ApproveToolCall = (request: PendingApproval) => boolean | Promise; + +/** `agent.approvals`: the tool calls a `createAgent()` agent is paused on, and how to decide them. */ +export interface AgentApprovals { + /** Approvals this agent paused on in this process and that are not decided yet, oldest first. */ + list(): Promise; + /** + * Approves or rejects a paused tool call (`note` is passed to the model + * with a rejection) and continues the run, resolving with the continued + * run's result - which may pause again. A run paused inside + * `agent.session()` continues in that session. Throws when `id` is unknown + * or already resolved. + * + * @example + * ```ts + * const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + * ``` + */ + resolve(decision: ApprovalDecision, options?: { signal?: AbortSignal }): Promise; +} + +/** resumeAfterApproval() bound to an agent's registry, provider and options. */ +type ResumeRun = (store: ApprovalStore, decision: ApprovalDecision, signal?: AbortSignal) => Promise; + +/** A session whose paused turn can be continued by `agent.approvals.resolve()`. */ +class ApprovalSession extends AgentSession { + resolveWith(next: () => Promise): Promise { + return this.continueTurn(next); + } +} + +/** Wires an agent's approval store, `approve` callback and resume function together. */ +export function createAgentApprovals(options: { store: ApprovalStore; approve?: ApproveToolCall; resume: ResumeRun }) { + const { approve, resume } = options; + const pending = new Map(); + const sessions = new Map(); + const store: ApprovalStore = { + async save(request, snapshot) { + await options.store.save(request, snapshot); + pending.set(request.id, request); + }, + async resolve(id) { + const record = await options.store.resolve(id); + pending.delete(id); + return record; + }, + }; + + /** With an `approve` callback, decides every pause until the run finishes. */ + async function settle(result: ExecutionResult, signal?: AbortSignal): Promise { + let current = result; + for (;;) { + const request = current.approvalId ? pending.get(current.approvalId) : undefined; + if (!approve || current.finishReason !== 'awaiting-approval' || !request) return current; + current = await resume(store, { id: request.id, approved: await approve(request) }, signal); + } + } + + function inSession(session: ApprovalSession | undefined, result: ExecutionResult): ExecutionResult { + if (session && result.finishReason === 'awaiting-approval' && result.approvalId) { + sessions.set(result.approvalId, session); + } + return result; + } + + const approvals: AgentApprovals = { + list: async () => [...pending.values()], + resolve(decision, { signal } = {}) { + const session = sessions.get(decision.id); + sessions.delete(decision.id); + const next = async () => inSession(session, await settle(await resume(store, decision, signal), signal)); + return session ? session.resolveWith(next) : next(); + }, + }; + + return { + /** The store to run with: the agent's store, recording what is pending. */ + store, + approvals, + settle, + session(run: SessionRunner, sessionOptions?: SessionOptions): AgentSession { + const session: ApprovalSession = new ApprovalSession( + async (input, signal) => inSession(session, await settle(await run(input, signal), signal)), + sessionOptions + ); + return session; + }, + }; +} diff --git a/src/execution/InMemoryApprovalStore.ts b/src/execution/InMemoryApprovalStore.ts new file mode 100644 index 000000000..e709c43f2 --- /dev/null +++ b/src/execution/InMemoryApprovalStore.ts @@ -0,0 +1,33 @@ +/** + * In-memory ApprovalStore (LOU-D21): the default store of `createAgent()` + * agents. Pending approvals live only as long as the process; pass a durable + * store (`StorageServiceApprovalStore`, `SqliteStore.approvals`) to resume + * after a restart. + */ + +import type { ApprovalStore, ExecutionSnapshot, PendingApproval, ResolvedApproval } from './ApprovalGate'; + +/** + * Keeps pending approvals in a Map. Records are copied on save, so later + * changes to the paused run's messages do not leak into the snapshot. + * + * @example + * ```ts + * const approvalStore = new InMemoryApprovalStore(); + * const paused = await AgentExecutor.execute({ agent, input, provider, toolRegistry, approvalStore }); + * ``` + */ +export class InMemoryApprovalStore implements ApprovalStore { + private readonly records = new Map(); + + async save(pending: PendingApproval, snapshot: ExecutionSnapshot): Promise { + this.records.set(pending.id, structuredClone({ pending, snapshot })); + } + + async resolve(id: string): Promise { + const record = this.records.get(id); + if (!record) return null; + this.records.delete(id); + return record; + } +} diff --git a/src/execution/index.ts b/src/execution/index.ts index 99a16cd48..cc9f7630f 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -10,6 +10,7 @@ export * from './ContextBuilder'; export * from './errors'; export * from './retry'; export * from './ApprovalGate'; +export { InMemoryApprovalStore } from './InMemoryApprovalStore'; export * from './resume'; export * from './checkpoint'; export * from './tracing'; diff --git a/src/index.ts b/src/index.ts index 56f417f12..d58086678 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ export * from './utils'; // createAgent() convenience API (LOU-H1) export * from './createAgent'; +export type { AgentApprovals, ApproveToolCall } from './createAgentApprovals'; // Sessions: multi-turn conversations for createAgent() (LOU-W4) export * from './session'; diff --git a/src/session/AgentSession.ts b/src/session/AgentSession.ts index 167cf506f..8867fbec1 100644 --- a/src/session/AgentSession.ts +++ b/src/session/AgentSession.ts @@ -133,7 +133,22 @@ export class AgentSession { private async turn(input: string, signal?: AbortSignal): Promise { await this.ensureLoaded(); - const result = await this.run([...this.history, { role: 'user', content: input }], signal); + return this.record(await this.run([...this.history, { role: 'user', content: input }], signal)); + } + + /** + * Runs `next` as this session's next turn (after any queued `send()`) and + * records the transcript it returns. `createAgent()` uses it to continue a + * session whose turn paused for approval (LOU-D21). + */ + protected continueTurn(next: () => Promise): Promise { + return this.enqueue(async () => { + await this.ensureLoaded(); + return this.record(await next()); + }); + } + + private async record(result: ExecutionResult): Promise { if (result.finishReason === 'aborted') return result; const withoutSystem = result.messages[0]?.role === 'system' ? result.messages.slice(1) : result.messages; const next = providerValidPrefix(withoutSystem); diff --git a/src/tools/mcp/server/serveMcp.test.ts b/src/tools/mcp/server/serveMcp.test.ts index d8cc670c7..fc4b603fa 100644 --- a/src/tools/mcp/server/serveMcp.test.ts +++ b/src/tools/mcp/server/serveMcp.test.ts @@ -128,13 +128,13 @@ describe('serveMcp agent tool', () => { expect(textOf(result)).toMatch(/needs human approval.*cannot be approved over MCP/s); }); - it('returns isError when an approval-gated tool is hit by an agent without an approval store', async () => { + it('returns isError when a createAgent() agent pauses on an approval-gated tool (LOU-D21)', async () => { const model = mockModel([{ toolCalls: [{ name: 'delete_all' }] }, 'done']); const agent = createAgent({ prompt: 'p', provider: model, tools: [deleteAll] }); const client = await connect(spec(agent)); const result = await client.callTool({ name: 'support-bot', arguments: { message: 'wipe' } }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('requires approval'); + expect(textOf(result)).toMatch(/needs human approval.*cannot be approved over MCP/s); }); it('reports an aborted run as an error', async () => {