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
29 changes: 28 additions & 1 deletion docs/src/content/docs/guides/human-in-the-loop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.

<Code
lang="typescript"
code={serverOwnedApprovalExample}
title="Server-owned approval state"
/>

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

Expand Down
4 changes: 4 additions & 0 deletions examples/agent-patterns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
286 changes: 286 additions & 0 deletions examples/agent-patterns/human-in-the-loop-server.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<PendingApproval> {
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<string, unknown> = {
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([]);
});
});
Loading
Loading