diff --git a/docs/src/content/docs/guides/human-in-the-loop.mdx b/docs/src/content/docs/guides/human-in-the-loop.mdx index 4c0562d38..c20b8dfb9 100644 --- a/docs/src/content/docs/guides/human-in-the-loop.mdx +++ b/docs/src/content/docs/guides/human-in-the-loop.mdx @@ -5,6 +5,7 @@ description: Add a human in the loop check for your agent executions import { Aside, Code } from '@astrojs/starlight/components'; import humanInTheLoopExample from '../../../../../examples/docs/human-in-the-loop/index.ts?raw'; +import serverOwnedApprovalExample from '../../../../../examples/docs/human-in-the-loop/server.ts?raw'; import toolApprovalDefinition from '../../../../../examples/docs/human-in-the-loop/toolApprovalDefinition.ts?raw'; This guide covers the SDK's approval-based human-in-the-loop flow. When a tool call requires approval, the SDK pauses the run, returns `interruptions`, and lets you resume later from the same `RunState`. @@ -109,7 +110,33 @@ Serialized run state includes your app context plus SDK-managed runtime metadata By default tracing API keys are omitted from serialized state so you do not accidentally persist secrets. Pass `result.state.toString({ includeTracingApiKey: true })` only when you intentionally need to move tracing credentials with the state. -That way you can store your serialized state in a database, or along with your request. +Store serialized state in application-controlled storage, such as a server-side database. + +### Keep approval state on the server + +Serialized `RunState` contains execution state, including approval decisions, pending tool calls, tool arguments, and application context. `RunState.fromString()` restores that state; the method does not authenticate the snapshot or the person submitting it. Only deserialize snapshots from trusted storage or after your application verifies the complete snapshot's integrity, ownership, and replay protection. Schema validation and tool-call fingerprints do not authenticate a snapshot. SDK references to agent or generated-item ownership also do not authorize an application user. + +For browser or mobile approval interfaces, keep the complete snapshot in application-controlled server storage. Send the reviewer only authorized display details and opaque identifiers for the pending decisions. A run ID or decision ID is not proof of authorization. Treat tool names and arguments as untrusted display content: filter sensitive values and escape content when rendering HTML. Keep full run results and errors on the server, and return only application-selected output to the client. + +When a decision arrives, the server must: + +1. Authenticate the reviewer using the application's session or authentication middleware. Never obtain the authenticated identity from the approval request body. +2. Authorize the reviewer against the stored run and the selected pending calls. +3. Validate decision identifiers and boolean decisions against the pending requests stored on the server. Do not accept replacement tool calls, arguments, approval records, or serialized state from the client. +4. Atomically check ownership and consume the pending request before deserialization and resumed execution. Concurrent or replayed submissions must not resume the same snapshot twice. In shared storage, use a transaction or equivalent atomic conditional transition. +5. Load the server-owned snapshot, obtain pending items with `state.getInterruptions()`, and apply `state.approve()` or `state.reject()` only to those items before resuming the run. + +The following example requires one decision for every pending call in a batch. This is an application policy; the SDK also supports partial approval as described above. The store is confined to one event loop in one process. The owner check, validation, and consumption have no intervening `await`. A request remains consumed if deserialization or execution fails, or if execution is cancelled. Consumption prevents resubmitting this snapshot; it does not guarantee exactly-once tool side effects. Reconcile completed or uncertain tool side effects before initiating recovery or another run. + + + +See the [CLI client/server simulation](https://github.com/openai/openai-agents-js/tree/main/examples/agent-patterns/human-in-the-loop-server.ts) for the complete example. This is not a deployable HTTP service. Production applications must supply trusted authentication, authorization for the displayed details and selected calls, request and CSRF protections where applicable, bounded storage retention, atomic consumption in shared storage, and a recovery policy. The CLI uses synthetic tool data and disables tracing; real application snapshots and diagnostics can contain sensitive data. + +`RunState.fromStringWithContext()` with `contextStrategy: 'replace'`, or removing only serialized approval records, does not make an untrusted snapshot safe. Other fields still control execution. If a client transports the complete snapshot, verify the complete snapshot's integrity, bind it to the authorized user and run, and prevent replay before deserialization. Integrity verification does not encrypt the snapshot or hide its contents from the client. ### Versioning pending tasks diff --git a/examples/agent-patterns/README.md b/examples/agent-patterns/README.md index 8885daecb..4f6f8affe 100644 --- a/examples/agent-patterns/README.md +++ b/examples/agent-patterns/README.md @@ -26,6 +26,10 @@ This directory contains small scripts that demonstrate different agent patterns. ```bash pnpm examples:human-in-the-loop ``` +- `human-in-the-loop-server.ts` – Keep approval snapshots on the server and accept only owner-authorized decisions. This CLI simulates a client and server in one process; it is not an HTTP service. The store is confined to one event loop and requires a complete decision batch. Consumed requests cannot be retried, including after failure or cancellation. Production applications need authentication, request protections, bounded storage retention, atomic owner-checked consumption in shared storage, and recovery that reconciles tool side effects. + ```bash + pnpm -F agent-patterns start:human-in-the-loop-server + ``` - `human-in-the-loop-stream.ts` – Streaming version of human approval. ```bash pnpm examples:streamed:human-in-the-loop diff --git a/examples/agent-patterns/human-in-the-loop-server.test.ts b/examples/agent-patterns/human-in-the-loop-server.test.ts new file mode 100644 index 000000000..050186a9f --- /dev/null +++ b/examples/agent-patterns/human-in-the-loop-server.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Agent, RunState, tool } from '@openai/agents'; +import { + ScriptedModel, + assistantMessage, + functionCall, + modelResponder, +} from '@openai/agents/testing'; +import { z } from 'zod'; +import { + ApprovalServer, + type PendingApproval, +} from '../docs/human-in-the-loop/server'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function makeServer( + options: { + multiple?: boolean; + repeat?: boolean; + outcome?: 'success' | 'failure' | 'cancel'; + } = {}, +) { + const calls: string[] = []; + const started = deferred(); + const release = deferred(); + const firstCalls = [ + functionCall( + 'send_report', + { destination: 'original' }, + { callId: 'call-1' }, + ), + ]; + if (options.multiple) + firstCalls.push( + functionCall( + 'send_report', + { destination: 'second' }, + { callId: 'call-2' }, + ), + ); + const model = new ScriptedModel([ + firstCalls, + ...(options.repeat + ? [ + [ + functionCall( + 'send_report', + { destination: 'second' }, + { callId: 'call-2' }, + ), + ], + ] + : []), + modelResponder(async () => { + if (options.outcome) { + started.resolve(); + await release.promise; + if (options.outcome === 'failure') + throw new Error('Synthetic model failure'); + } + return [assistantMessage('done')]; + }), + ]); + const server = new ApprovalServer( + new Agent({ + name: 'Reports', + model, + tools: [ + tool({ + name: 'send_report', + description: 'Record a synthetic destination.', + parameters: z.object({ destination: z.string() }), + needsApproval: true, + execute: async ({ destination }) => { + calls.push(destination); + return 'sent'; + }, + }), + ], + }), + ); + return { server, calls, started, release, model }; +} + +async function pending(server: ApprovalServer): Promise { + const result = await server.start('owner', 'Send a synthetic report'); + expect(result.kind).toBe('approval'); + if (result.kind !== 'approval') throw new Error('Expected pending approval'); + return result; +} + +function decisionsFor(request: PendingApproval, approved = true) { + return Object.fromEntries( + request.prompts.map((prompt) => [prompt.decisionId, approved]), + ); +} + +describe('server-owned approval example', () => { + it.each([true, false])( + 'keeps state private and applies the owner decision %s to stored arguments', + async (approved) => { + const { server, calls, model } = makeServer(); + const request = await pending(server); + expect(Object.keys(request).sort()).toEqual([ + 'kind', + 'prompts', + 'requestId', + ]); + expect(Object.keys(request.prompts[0]).sort()).toEqual([ + 'arguments', + 'decisionId', + 'toolName', + ]); + request.prompts[0].arguments = '{"destination":"changed by client"}'; + request.prompts[0].toolName = 'another_tool'; + const decisions = decisionsFor(request, approved); + const result = server.decide('owner', request.requestId, decisions); + // Mutation after submission cannot change the copied decision either. + decisions[request.prompts[0].decisionId] = !approved; + expect(await result).toEqual({ kind: 'completed', output: 'done' }); + expect(calls).toEqual(approved ? ['original'] : []); + await expect( + server.decide('owner', request.requestId, decisions), + ).rejects.toThrow('unavailable'); + model.assertComplete(); + }, + ); + + it('rejects foreign owners and unknown IDs without consuming the owned request', async () => { + const { server, calls } = makeServer(); + const request = await pending(server); + const decisions = decisionsFor(request); + await expect( + server.decide('other', request.requestId, decisions), + ).rejects.toThrow('unavailable'); + await expect(server.decide('owner', 'unknown', decisions)).rejects.toThrow( + 'unavailable', + ); + expect(calls).toEqual([]); + await server.decide('owner', request.requestId, decisions); + expect(calls).toEqual(['original']); + }); + + it.each([ + 'missing', + 'foreign', + 'extra', + 'non_boolean', + 'snapshot', + 'null', + 'array', + ])('rejects an invalid %s batch before consumption', async (kind) => { + const { server, calls } = makeServer(); + const request = await pending(server); + const id = request.prompts[0].decisionId; + const invalid: Record = { + missing: {}, + foreign: { unknown: true }, + extra: { [id]: true, unknown: false }, + non_boolean: { [id]: 'true' }, + snapshot: { [id]: true, context: { approvals: { send_report: true } } }, + null: null, + array: [true], + }; + await expect( + server.decide('owner', request.requestId, invalid[kind]), + ).rejects.toThrow('one boolean decision'); + expect(calls).toEqual([]); + await server.decide('owner', request.requestId, { [id]: false }); + expect(calls).toEqual([]); + }); + + it('requires a complete batch and maps decisions by stored IDs, not submission order', async () => { + const { server, calls } = makeServer({ multiple: true }); + const request = await pending(server); + const [first, second] = request.prompts; + await expect( + server.decide('owner', request.requestId, { [first.decisionId]: true }), + ).rejects.toThrow('one boolean decision'); + expect(calls).toEqual([]); + await server.decide('owner', request.requestId, { + [second.decisionId]: false, + [first.decisionId]: true, + }); + expect(calls).toEqual(['original']); + }); + + it('binds a new approval batch to new owner-checked IDs', async () => { + const { server, calls } = makeServer({ repeat: true }); + const first = await pending(server); + const second = await server.decide( + 'owner', + first.requestId, + decisionsFor(first), + ); + if (second.kind !== 'approval') + throw new Error('Expected another approval'); + expect(second.requestId).not.toBe(first.requestId); + expect(second.prompts[0].decisionId).not.toBe(first.prompts[0].decisionId); + await expect( + server.decide('owner', second.requestId, decisionsFor(first)), + ).rejects.toThrow('one boolean decision'); + await expect( + server.decide('other', second.requestId, decisionsFor(second)), + ).rejects.toThrow('unavailable'); + await server.decide('owner', second.requestId, decisionsFor(second, false)); + expect(calls).toEqual(['original']); + }); + + it.each(['success', 'failure', 'cancel'] as const)( + 'prevents in-flight and later replay after %s', + async (outcome) => { + const { server, calls, started, release } = makeServer({ outcome }); + const request = await pending(server); + const decisions = decisionsFor(request); + const controller = new AbortController(); + const first = server.decide( + 'owner', + request.requestId, + decisions, + controller.signal, + ); + // Attach a handler before cancellation can reject the pending run. + const settled = first.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + try { + await started.promise; + await expect( + server.decide('owner', request.requestId, decisions), + ).rejects.toThrow('unavailable'); + if (outcome === 'cancel') + controller.abort(new Error('Synthetic cancellation')); + release.resolve(); + const result = await settled; + if (outcome === 'success') + expect(result).toEqual({ + value: { kind: 'completed', output: 'done' }, + }); + else { + expect(result).toHaveProperty('error'); + if ('error' in result) + expect(String(result.error)).toMatch( + outcome === 'failure' + ? /Synthetic model failure/ + : /Synthetic cancellation|aborted/, + ); + } + await expect( + server.decide('owner', request.requestId, decisions), + ).rejects.toThrow('unavailable'); + expect(calls).toEqual(['original']); + } finally { + release.resolve(); + await settled; + } + }, + ); + + it('consumes before deserialization, including a restore failure', async () => { + const { server, calls } = makeServer(); + const request = await pending(server); + const restore = vi + .spyOn(RunState, 'fromString') + .mockRejectedValueOnce(new Error('Synthetic restore failure')); + try { + await expect( + server.decide('owner', request.requestId, decisionsFor(request)), + ).rejects.toThrow('Synthetic restore failure'); + } finally { + restore.mockRestore(); + } + await expect( + server.decide('owner', request.requestId, decisionsFor(request)), + ).rejects.toThrow('unavailable'); + expect(calls).toEqual([]); + }); +}); diff --git a/examples/agent-patterns/human-in-the-loop-server.ts b/examples/agent-patterns/human-in-the-loop-server.ts new file mode 100644 index 000000000..8f4250450 --- /dev/null +++ b/examples/agent-patterns/human-in-the-loop-server.ts @@ -0,0 +1,67 @@ +/** + * A CLI simulation, not an HTTP service or authentication implementation. + * Complete snapshots and RunResult objects stay in the server implementation. + * Production needs trusted authentication, request protections, atomic shared + * storage, bounded retention, and recovery that reconciles tool side effects. + */ +import { Agent, tool } from '@openai/agents'; +import { z } from 'zod'; +import readline from 'node:readline/promises'; +import { ApprovalServer } from '../docs/human-in-the-loop/server'; + +async function main() { + const server = new ApprovalServer( + new Agent({ + name: 'Weather assistant', + instructions: 'Use get_temperature to answer temperature questions.', + tools: [ + tool({ + name: 'get_temperature', + description: 'Return a sample temperature for a city.', + parameters: z.object({ city: z.string() }), + needsApproval: true, + execute: async ({ city }) => + `The temperature in ${city} is 20 Celsius.`, + }), + ], + }), + ); + // Server-side simulation of authenticated middleware; never a request-body field. + const authenticatedUserId = 'example-user'; + let response = await server.start( + authenticatedUserId, + 'What is the temperature in Oakland?', + ); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + while (response.kind === 'approval') { + const decisions: Record = {}; + for (const prompt of response.prompts) { + // JSON quoting also keeps terminal control characters out of the prompt. + const answer = await rl.question( + `Allow ${JSON.stringify(prompt.toolName)} with ${JSON.stringify(prompt.arguments)}? (y/n): `, + ); + decisions[prompt.decisionId] = answer.trim().toLowerCase() === 'y'; + } + response = await server.decide( + authenticatedUserId, + response.requestId, + decisions, + ); + } + console.log(response.output); + } finally { + rl.close(); + } +} + +main().catch(() => { + // Raw SDK errors can contain execution state. Keep diagnostics server-side. + console.error( + 'The approval run failed. Reconcile side effects before retrying.', + ); + process.exitCode = 1; +}); diff --git a/examples/agent-patterns/human-in-the-loop.ts b/examples/agent-patterns/human-in-the-loop.ts index 4862e53d1..c7d57ebd1 100644 --- a/examples/agent-patterns/human-in-the-loop.ts +++ b/examples/agent-patterns/human-in-the-loop.ts @@ -1,3 +1,5 @@ +// This local CLI trusts its saved state. Browser/mobile approval UIs should keep +// snapshots on the server; see human-in-the-loop-server.ts in agent-patterns. import { z } from 'zod'; import readline from 'node:readline/promises'; import fs from 'node:fs/promises'; diff --git a/examples/agent-patterns/package.json b/examples/agent-patterns/package.json index 1a082ba38..b25e2c63d 100644 --- a/examples/agent-patterns/package.json +++ b/examples/agent-patterns/package.json @@ -25,7 +25,8 @@ "start:parallelization": "node parallelization.ts", "start:routing": "node routing.ts", "start:streamed": "node streamed.ts", - "start:streaming-guardrails": "node streaming-guardrails.ts" + "start:streaming-guardrails": "node streaming-guardrails.ts", + "start:human-in-the-loop-server": "tsx human-in-the-loop-server.ts" }, "engines": { "node": "^22.18.0 || ^24.0.0 || >=26.0.0" diff --git a/examples/docs/human-in-the-loop/index.ts b/examples/docs/human-in-the-loop/index.ts index 5e7e7d9e8..d4de81361 100644 --- a/examples/docs/human-in-the-loop/index.ts +++ b/examples/docs/human-in-the-loop/index.ts @@ -1,3 +1,5 @@ +// This local CLI trusts its saved state. Browser/mobile approval UIs should keep +// snapshots on the server; see human-in-the-loop-server.ts in agent-patterns. import { z } from 'zod'; import readline from 'node:readline/promises'; import fs from 'node:fs/promises'; diff --git a/examples/docs/human-in-the-loop/server.ts b/examples/docs/human-in-the-loop/server.ts new file mode 100644 index 000000000..2b7de1cc6 --- /dev/null +++ b/examples/docs/human-in-the-loop/server.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; +import { Agent, Runner, RunState, type RunResult } from '@openai/agents'; +import { z } from 'zod'; + +export type PendingApproval = { + kind: 'approval'; + requestId: string; + prompts: { decisionId: string; toolName: string; arguments: string }[]; +}; + +type StoredRun = { + ownerId: string; + snapshot: string; + decisionIds: string[]; +}; + +// Simulation only: one event loop in one process. Production storage needs an +// atomic owner-checked consume operation and bounded retention. Consumption is +// permanent even after failure/cancellation; reconcile side effects before retry. +export class ApprovalServer { + #agent: Agent; + #runner = new Runner({ tracingDisabled: true }); + #pending = new Map(); + + constructor(agent: Agent) { + this.#agent = agent; + } + + // An HTTP adapter must obtain this identity from trusted authentication + // middleware and apply request/CSRF protections. Never read it from the body. + async start(authenticatedUserId: string, message: string) { + const result = await this.#runner.run(this.#agent, message); + return this.#save(authenticatedUserId, result); + } + + #save>( + ownerId: string, + result: RunResult, + ) { + const interruptions = result.state.getInterruptions(); + if (interruptions.length === 0) { + // RunResult stays on the server; the app selects what the client may see. + return { kind: 'completed' as const, output: result.finalOutput }; + } + const requestId = randomUUID(); + const decisionIds = interruptions.map(() => randomUUID()); + this.#pending.set(requestId, { + ownerId, + snapshot: result.state.toString(), + decisionIds, + }); + const response: PendingApproval = { + kind: 'approval', + requestId, + // Detached display values only. Filter arguments for the reviewer's access + // policy; this demo uses synthetic weather data. Escape HTML in a web UI. + prompts: interruptions.map((item, index) => ({ + decisionId: decisionIds[index], + toolName: item.name ?? 'unknown_tool', + arguments: item.arguments ?? '', + })), + }; + return response; + } + + async decide( + authenticatedUserId: string, + requestId: string, + decisions: unknown, + signal?: AbortSignal, + ) { + const stored = this.#pending.get(requestId); + if (!stored || stored.ownerId !== authenticatedUserId) { + throw new Error('Approval request is unavailable.'); + } + // Validate JSON request data, not client-supplied calls, state, or identity. + const parsed = z.record(z.string(), z.boolean()).safeParse(decisions); + if ( + !parsed.success || + Object.keys(parsed.data).length !== stored.decisionIds.length || + !stored.decisionIds.every((id) => + Object.prototype.hasOwnProperty.call(parsed.data, id), + ) + ) { + throw new Error( + 'Provide one boolean decision for every pending tool call.', + ); + } + // No await between the owner check and consume. The parsed decisions are a + // detached copy, so client mutation while deserialization awaits has no effect. + this.#pending.delete(requestId); + const state = await RunState.fromString(this.#agent, stored.snapshot); + const interruptions = state.getInterruptions(); + for (const [index, interruption] of interruptions.entries()) { + if (parsed.data[stored.decisionIds[index]]) { + state.approve(interruption); + } else { + state.reject(interruption); + } + } + const result = await this.#runner.run(this.#agent, state, { signal }); + return this.#save(authenticatedUserId, result); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 5f7850d83..7b10b5c4c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -108,6 +108,7 @@ function createProjects(reviewTestProfile: boolean) { 'scripts/run-integration-tests-managed.test.mjs', 'scripts/workflow-contracts.test.mjs', 'docs/src/scripts/headingAnchors.test.ts', + 'examples/agent-patterns/human-in-the-loop-server.test.ts', ], }, },