Skip to content

Commit 276eb29

Browse files
committed
HITL support
1 parent a44370a commit 276eb29

File tree

15 files changed

+851
-327
lines changed

15 files changed

+851
-327
lines changed

examples/memory/file-hitl.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { z } from 'zod';
2+
import readline from 'node:readline/promises';
3+
import { stdin as input, stdout as output } from 'node:process';
4+
import {
5+
Agent,
6+
RunResult,
7+
RunToolApprovalItem,
8+
run,
9+
tool,
10+
} from '@openai/agents';
11+
12+
import type { Interface as ReadlineInterface } from 'node:readline/promises';
13+
import { FileSession } from './sessions';
14+
15+
const lookupCustomerProfile = tool({
16+
name: 'lookup_customer_profile',
17+
description:
18+
'Look up stored profile details for a customer by their internal id.',
19+
parameters: z.object({
20+
id: z
21+
.string()
22+
.describe('The internal identifier for the customer to retrieve.'),
23+
}),
24+
async needsApproval() {
25+
return true;
26+
},
27+
async execute({ id }) {
28+
const record = customerDirectory[id];
29+
if (!record) {
30+
return `No customer found for id ${id}.`;
31+
}
32+
return `Customer ${record.name} (tier ${record.tier}) can be reached at ${record.phone}. Notes: ${record.notes}`;
33+
},
34+
});
35+
36+
const customerDirectory: Record<
37+
string,
38+
{ name: string; phone: string; tier: string; notes: string }
39+
> = {
40+
'101': {
41+
name: 'Amina K.',
42+
phone: '+1-415-555-1010',
43+
tier: 'gold',
44+
notes: 'Prefers SMS follow ups and values concise summaries.',
45+
},
46+
'104': {
47+
name: 'Diego L.',
48+
phone: '+1-415-555-2040',
49+
tier: 'platinum',
50+
notes:
51+
'Recently reported sync issues. Flagged for a proactive onboarding call.',
52+
},
53+
'205': {
54+
name: 'Morgan S.',
55+
phone: '+1-415-555-3205',
56+
tier: 'standard',
57+
notes: 'Interested in automation tutorials sent last week.',
58+
},
59+
};
60+
61+
function formatToolArguments(interruption: RunToolApprovalItem): string {
62+
const args = interruption.rawItem.arguments;
63+
if (!args) {
64+
return '';
65+
}
66+
if (typeof args === 'string') {
67+
return args;
68+
}
69+
try {
70+
return JSON.stringify(args);
71+
} catch {
72+
return String(args);
73+
}
74+
}
75+
76+
async function promptYesNo(
77+
rl: ReadlineInterface,
78+
question: string,
79+
): Promise<boolean> {
80+
const answer = await rl.question(`${question} (y/n): `);
81+
const normalized = answer.trim().toLowerCase();
82+
return normalized === 'y' || normalized === 'yes';
83+
}
84+
85+
async function resolveInterruptions<TContext, TAgent extends Agent<any, any>>(
86+
rl: ReadlineInterface,
87+
agent: TAgent,
88+
initialResult: RunResult<TContext, TAgent>,
89+
session: FileSession,
90+
): Promise<RunResult<TContext, TAgent>> {
91+
let result = initialResult;
92+
while (result.interruptions?.length) {
93+
for (const interruption of result.interruptions) {
94+
const args = formatToolArguments(interruption);
95+
const approved = await promptYesNo(
96+
rl,
97+
`Agent ${interruption.agent.name} wants to call ${interruption.rawItem.name} with ${args || 'no arguments'}`,
98+
);
99+
if (approved) {
100+
result.state.approve(interruption);
101+
console.log('Approved tool call.');
102+
} else {
103+
result.state.reject(interruption);
104+
console.log('Rejected tool call.');
105+
}
106+
}
107+
108+
result = await run(agent, result.state, { session });
109+
}
110+
111+
return result;
112+
}
113+
114+
async function main() {
115+
const agent = new Agent({
116+
name: 'File HITL assistant',
117+
instructions:
118+
'You assist support agents. Always consult the lookup_customer_profile tool before answering customer questions so your replies include stored notes. Keep responses under three sentences.',
119+
modelSettings: { toolChoice: 'required' },
120+
tools: [lookupCustomerProfile],
121+
});
122+
123+
const session = new FileSession({ dir: './tmp' });
124+
const sessionId = await session.getSessionId();
125+
const rl = readline.createInterface({ input, output });
126+
127+
console.log(`Session id: ${sessionId}`);
128+
console.log(
129+
'Enter a message to chat with the agent. Submit an empty line to exit.',
130+
);
131+
132+
while (true) {
133+
const userMessage = await rl.question('You: ');
134+
if (!userMessage.trim()) {
135+
break;
136+
}
137+
138+
let result = await run(agent, userMessage, { session });
139+
result = await resolveInterruptions(rl, agent, result, session);
140+
141+
const reply = result.finalOutput ?? '[No final output produced]';
142+
console.log(`Assistant: ${reply}`);
143+
console.log();
144+
}
145+
146+
rl.close();
147+
}
148+
149+
main().catch((error) => {
150+
console.error(error);
151+
process.exit(1);
152+
});

examples/memory/file.ts

Lines changed: 27 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,31 @@
1-
import type { AgentInputItem, Session } from '@openai/agents';
2-
import { protocol } from '@openai/agents';
3-
import * as fs from 'node:fs/promises';
4-
import * as path from 'node:path';
5-
import { randomUUID } from 'node:crypto';
6-
7-
export type FileSessionOptions = {
8-
/**
9-
* Directory where session files are stored. Defaults to `./.agents-sessions`.
10-
*/
11-
dir?: string;
12-
/**
13-
* Optional pre-existing session id to bind to.
14-
*/
15-
sessionId?: string;
16-
};
17-
18-
/**
19-
* A simple filesystem-backed Session implementation that stores history as a JSON array.
20-
*/
21-
export class FileSession implements Session {
22-
#dir: string;
23-
#sessionId?: string;
24-
25-
constructor(options: FileSessionOptions = {}) {
26-
this.#dir = options.dir ?? path.resolve(process.cwd(), '.agents-sessions');
27-
this.#sessionId = options.sessionId;
28-
}
29-
30-
/**
31-
* Get the current session id, creating one if necessary.
32-
*/
33-
async getSessionId(): Promise<string> {
34-
if (!this.#sessionId) {
35-
// Compact, URL-safe-ish id without dashes.
36-
this.#sessionId = randomUUID().replace(/-/g, '').slice(0, 24);
37-
}
38-
await this.#ensureDir();
39-
// Ensure the file exists.
40-
const file = this.#filePath(this.#sessionId);
41-
try {
42-
await fs.access(file);
43-
} catch {
44-
await fs.writeFile(file, '[]', 'utf8');
45-
}
46-
return this.#sessionId;
47-
}
48-
49-
/**
50-
* Retrieve items from the conversation history.
51-
*/
52-
async getItems(limit?: number): Promise<AgentInputItem[]> {
53-
const sessionId = await this.getSessionId();
54-
const items = await this.#readItems(sessionId);
55-
if (typeof limit === 'number' && limit >= 0) {
56-
return items.slice(-limit);
57-
}
58-
return items;
59-
}
60-
61-
/**
62-
* Append new items to the conversation history.
63-
*/
64-
async addItems(items: AgentInputItem[]): Promise<void> {
65-
if (!items.length) return;
66-
const sessionId = await this.getSessionId();
67-
const current = await this.#readItems(sessionId);
68-
const next = current.concat(items);
69-
await this.#writeItems(sessionId, next);
70-
}
71-
72-
/**
73-
* Remove and return the most recent item, if any.
74-
*/
75-
async popItem(): Promise<AgentInputItem | undefined> {
76-
const sessionId = await this.getSessionId();
77-
const items = await this.#readItems(sessionId);
78-
if (items.length === 0) return undefined;
79-
const popped = items.pop();
80-
await this.#writeItems(sessionId, items);
81-
return popped;
82-
}
83-
84-
/**
85-
* Delete all stored items and reset the session state.
86-
*/
87-
async clearSession(): Promise<void> {
88-
if (!this.#sessionId) return; // Nothing to clear.
89-
const file = this.#filePath(this.#sessionId);
90-
try {
91-
await fs.unlink(file);
92-
} catch {
93-
// Ignore if already removed or inaccessible.
94-
}
95-
this.#sessionId = undefined;
96-
}
97-
98-
// Internal helpers
99-
async #ensureDir(): Promise<void> {
100-
await fs.mkdir(this.#dir, { recursive: true });
101-
}
102-
103-
#filePath(sessionId: string): string {
104-
return path.join(this.#dir, `${sessionId}.json`);
105-
}
106-
107-
async #readItems(sessionId: string): Promise<AgentInputItem[]> {
108-
const file = this.#filePath(sessionId);
109-
try {
110-
const data = await fs.readFile(file, 'utf8');
111-
const parsed = JSON.parse(data);
112-
if (!Array.isArray(parsed)) return [];
113-
// Validate and coerce items to the protocol shape where possible.
114-
const result: AgentInputItem[] = [];
115-
for (const raw of parsed) {
116-
const check = protocol.ModelItem.safeParse(raw);
117-
if (check.success) {
118-
result.push(check.data as AgentInputItem);
119-
}
120-
// Silently skip invalid entries.
121-
}
122-
return result;
123-
} catch (err: any) {
124-
// On missing file, return empty list.
125-
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return [];
126-
// For other errors, rethrow.
127-
throw err;
128-
}
129-
}
130-
131-
async #writeItems(sessionId: string, items: AgentInputItem[]): Promise<void> {
132-
await this.#ensureDir();
133-
const file = this.#filePath(sessionId);
134-
// Keep JSON compact but deterministic.
135-
await fs.writeFile(file, JSON.stringify(items, null, 2), 'utf8');
136-
}
137-
}
138-
139-
import { Agent, run } from '@openai/agents';
1+
import { Agent, run, tool } from '@openai/agents';
2+
import { FileSession } from './sessions';
3+
import { z } from 'zod';
4+
5+
const lookupCustomerProfile = tool({
6+
name: 'lookup_customer_profile',
7+
description:
8+
'Look up stored profile details for a customer by their internal id.',
9+
parameters: z.object({
10+
id: z
11+
.string()
12+
.describe('The internal identifier for the customer to retrieve.'),
13+
}),
14+
async execute({ id }) {
15+
const directory: Record<string, string> = {
16+
'1': 'Customer 1 (tier gold). Notes: Prefers concise replies.',
17+
'2': 'Customer 2 (tier standard). Notes: Interested in tutorials.',
18+
};
19+
return directory[id] ?? `No customer found for id ${id}.`;
20+
},
21+
});
14022

14123
async function main() {
14224
const agent = new Agent({
14325
name: 'Assistant',
144-
instructions: 'You are a helpful assistant. be VERY concise.',
26+
instructions: 'You are a helpful assistant.',
27+
modelSettings: { toolChoice: 'required' },
28+
tools: [lookupCustomerProfile],
14529
});
14630

14731
const session = new FileSession({ dir: './tmp/' });
@@ -161,7 +45,9 @@ async function main() {
16145
async function mainStream() {
16246
const agent = new Agent({
16347
name: 'Assistant',
164-
instructions: 'You are a helpful assistant. be VERY concise.',
48+
instructions: 'You are a helpful assistant.',
49+
modelSettings: { toolChoice: 'required' },
50+
tools: [lookupCustomerProfile],
16551
});
16652

16753
const session = new FileSession({ dir: './tmp/' });

0 commit comments

Comments
 (0)