Skip to content

Commit 88eac04

Browse files
Agents OpenAI (TypeScript): offer source tools under their own names (#1650)
1 parent 43a2766 commit 88eac04

7 files changed

Lines changed: 125 additions & 26 deletions

File tree

‎agents/typescript/openai-agents/README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ Pass `toolSources: [refunds]` to `createOpenAIAgentsSessionFlow`.
226226
- Every `RunModel` lists each source and wraps the agent graph again, so every
227227
agent of the graph offers the listed tools after its own tools, as
228228
non-strict function tools with the source's description and JSON Schema.
229+
- The model sees each source tool by its name in the source, such as
230+
`fake-ledger_get_balance`, so `fake-ledger_get_balance` and
231+
`fake_ledger_get_balance` are two tools. A character other than a letter,
232+
digit, `_`, or `-` reaches the model as `_`, because the OpenAI API accepts
233+
no other. `toolPolicies`, records, and events always use the source's name.
229234
- A call of a source tool never runs inside the model call. The source
230235
builds the call, so its effect, its approval, and whether it is an ordered
231236
write come from the source; a tool's `needsApproval`, input guardrails, and `createContext` do not apply. A
@@ -251,8 +256,8 @@ Pass `toolSources: [refunds]` to `createOpenAIAgentsSessionFlow`.
251256
instead, also inside the turn. A resumed call replays only a record of the
252257
same tool name, once.
253258
- Definition fails with `TypeError` when a name a source declares repeats a
254-
tool or handoff of the graph or another source's tool. A listing that
255-
repeats one fails its `RunModel` attempt.
259+
tool or handoff of the graph or another source's tool, also after that `_`
260+
replacement. A listing that repeats one fails its `RunModel` attempt.
256261

257262
## Tracing
258263

‎agents/typescript/openai-agents/src/openai-agents-agent-graph.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,14 @@ export function listAgentGraphToolNames(startingAgent: AnyAgent): ReadonlySet<st
165165
}
166166

167167
/**
168-
* Returns the name the model sees for a function tool created with `name`. The SDK replaces every character
169-
* other than a letter or digit with `_`, so `fake-ledger_get_balance` becomes `fake_ledger_get_balance`.
168+
* Returns the name the model sees for a tool source's tool. A function name may hold letters, digits, `_`, and
169+
* `-`, so `fake-ledger_get_balance` stays as it is, and every other character becomes `_`.
170170
*
171-
* @param name - The name passed to `tool()`.
172-
* @returns The SDK's function tool name.
171+
* @param name - The tool's name in its source.
172+
* @returns The function tool name.
173173
*/
174174
export function readFunctionToolName(name: string): string {
175-
return createSourceFunctionTool({ name, description: "", inputSchema: {} }).name;
175+
return name.replace(/[^A-Za-z0-9_-]/g, "_");
176176
}
177177

178178
/**
@@ -286,7 +286,7 @@ export function describeReplayOnlyToolRefusal(sourceToolName: string): string {
286286

287287

288288
function createSourceFunctionTool(definition: SourceToolDefinition): AnyFunctionTool {
289-
return tool({
289+
const functionTool = tool({
290290
name: definition.name,
291291
description: definition.description,
292292
// A source's schema need not meet strict mode, such as a connector input with optional fields.
@@ -297,6 +297,8 @@ function createSourceFunctionTool(definition: SourceToolDefinition): AnyFunction
297297
throw new Error(`tool ${definition.name} of a tool source ran inside the model call`);
298298
},
299299
});
300+
// tool() also turns `-` into `_`, which would hide the source's name from the model.
301+
return cloneFunctionTool(functionTool, { name: readFunctionToolName(definition.name) });
300302
}
301303

302304
/** `toolName` is the name the call's record holds: the qualified function tool name, or the source's name. */

‎agents/typescript/openai-agents/src/openai-agents-runner.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ export class OpenAIAgentsRunner<TContext> implements AgentFrameworkRunner {
416416
};
417417
}
418418

419-
/** The SDK renames a function tool for the model, so two source names may meet one graph name. */
419+
/** The model sees `_` for a source name's other characters, so two source names may meet one graph name. */
420420
private mapSourceToolNamesToFunctionToolNames(sourceToolNames: readonly string[]): ReadonlyMap<string, string> {
421421
const functionToolNames = new Map<string, string>();
422422
const sourceToolNamesByFunctionToolName = new Map<string, string>();

‎agents/typescript/openai-agents/test/fixtures/connector-openai-agent.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// The scripted model reads `calls <json>` from the last user text, a JSON list of [toolName, arguments] pairs, and
1111
// calls them in one response with IDs call-0, call-1, and so on. Once results arrive it answers one
1212
// `<toolName>: <output>` line per result. A `hold:<gate>` word before `calls` makes that answer wait at a FileGate,
13-
// so a test can read the turn's records before the turn ends.
13+
// so a test can read the turn's records before the turn ends. The text `tools` answers the request's tool names.
1414

1515
import {
1616
Agent,
@@ -68,8 +68,8 @@ export function retriedNoteManifest(): ConnectorPackageManifest {
6868
}
6969

7070
/**
71-
* The ledger tools: a Query under a chosen and its default name, a payout without and with approval, a fenced note,
72-
* and that note retried.
71+
* The ledger tools: a Query under a chosen name, its default name, and that name with `_` for `-`, a payout without
72+
* and with approval, a fenced note, and that note retried.
7373
*/
7474
export function createLedgerToolSource(stateDirectory: string, sourceName?: string): ConnectorToolSource {
7575
const provider = new FakeLedgerProvider(stateDirectory);
@@ -83,11 +83,16 @@ export function createLedgerToolSource(stateDirectory: string, sourceName?: stri
8383
inputValidator: GetBalanceInputSchema,
8484
name: "get_balance",
8585
}),
86-
// The default name, fake-ledger_get_balance, which the OpenAI Agents SDK renames for the model.
86+
// The default name, fake-ledger_get_balance, which the OpenAI Agents SDK's tool() would turn into the next one.
8787
connectorTool(new GetBalanceOperation(provider), fakeLedgerConnection, {
8888
manifest,
8989
inputValidator: GetBalanceInputSchema,
9090
}),
91+
connectorTool(new GetBalanceOperation(provider), fakeLedgerConnection, {
92+
manifest,
93+
inputValidator: GetBalanceInputSchema,
94+
name: "fake_ledger_get_balance",
95+
}),
9196
connectorTool(payouts, fakeLedgerConnection, {
9297
manifest,
9398
inputValidator: CreatePayoutInputSchema,
@@ -127,7 +132,7 @@ export function createConnectorStartingAgent(stateDirectory: string): Agent {
127132
/** Creates the connector agent Flow; tests and the Worker child build it identically. */
128133
export function createConnectorOpenAIAgentFlow(stateDirectory: string): AgentSessionFlow {
129134
return createOpenAIAgentsSessionFlow(createConnectorStartingAgent(stateDirectory), connectorOpenAIAgentFlowType, {
130-
toolPolicies: { echo_text: { effect: "read" } },
135+
toolPolicies: { echo_text: { effect: "read" }, "fake-ledger_get_balance": { effect: "read", requiresApproval: true } },
131136
toolSources: [createLedgerToolSource(stateDirectory)],
132137
});
133138
}
@@ -181,6 +186,9 @@ class ConnectorScriptedModel implements Model {
181186
}
182187
return [answerItem(results.join("\n"))];
183188
}
189+
if (command === "tools") {
190+
return [answerItem(request.tools.map((requestTool) => requestTool.name).join("\n"))];
191+
}
184192
if (!command.startsWith("calls ")) {
185193
return [answerItem(`said: ${command}`)];
186194
}

‎agents/typescript/openai-agents/test/integ/connector-tools.integration.test.ts‎

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
type AgentToolCall,
2828
type AgentToolCallRecord,
2929
} from "@superdurable/dex-agents";
30-
import { connectorTool } from "@superdurable/dex-agents/connectors";
30+
import { ConnectorToolSource, connectorTool } from "@superdurable/dex-agents/connectors";
3131
import {
3232
AgentSessionTestClient,
3333
AgentTestEnvironment,
@@ -49,6 +49,8 @@ import {
4949
CloseAccountInputSchema,
5050
CloseAccountOperation,
5151
FakeLedgerProvider,
52+
GetBalanceInputSchema,
53+
GetBalanceOperation,
5254
fakeLedgerConnection,
5355
fakeLedgerStateDirectoryVariable,
5456
} from "../fixtures/fake-ledger.js";
@@ -100,11 +102,24 @@ describe("connector tools refused when the OpenAI Agents session is defined", ()
100102
);
101103
});
102104

103-
test("a source tool that the SDK renames to a function tool name is refused", () => {
105+
function createBalanceToolSource(toolNames: readonly string[]): ConnectorToolSource {
106+
const provider = new FakeLedgerProvider(stateDirectory);
107+
return new ConnectorToolSource(
108+
toolNames.map((name) =>
109+
connectorTool(new GetBalanceOperation(provider), fakeLedgerConnection, {
110+
manifest: readFakeLedgerManifest(),
111+
inputValidator: GetBalanceInputSchema,
112+
name,
113+
}),
114+
),
115+
);
116+
}
117+
118+
test("a source tool that the model sees under a function tool name is refused", () => {
104119
const startingAgent = createConnectorStartingAgent(stateDirectory).clone({
105120
tools: [
106121
tool({
107-
name: "fake_ledger_get_balance",
122+
name: "ledger_balance",
108123
description: "A local balance tool.",
109124
parameters: z.object({ accountId: z.string() }),
110125
execute: async () => "local",
@@ -114,13 +129,26 @@ describe("connector tools refused when the OpenAI Agents session is defined", ()
114129
assert.throws(
115130
() =>
116131
createOpenAIAgentsSessionFlow(startingAgent, "RenamedConnectorAgent", {
117-
toolSources: [createLedgerToolSource(stateDirectory)],
132+
toolSources: [createBalanceToolSource(["ledger.balance"])],
118133
}),
119134
{
120135
name: "TypeError",
121136
message:
122-
"tool fake-ledger_get_balance of a tool source becomes function tool fake_ledger_get_balance, the name of " +
123-
"a tool or handoff of the agent graph",
137+
"tool ledger.balance of a tool source becomes function tool ledger_balance, the name of a tool or handoff " +
138+
"of the agent graph",
139+
},
140+
);
141+
});
142+
143+
test("two source tools that the model would see under one name are refused", () => {
144+
assert.throws(
145+
() =>
146+
createOpenAIAgentsSessionFlow(createConnectorStartingAgent(stateDirectory), "CollidingSourceToolsAgent", {
147+
toolSources: [createBalanceToolSource(["ledger.balance", "ledger_balance"])],
148+
}),
149+
{
150+
name: "TypeError",
151+
message: "tools ledger.balance and ledger_balance of tool sources both become function tool ledger_balance",
124152
},
125153
);
126154
});
@@ -245,14 +273,51 @@ describe("connector tools in an OpenAI Agents session on a Dex engine", () => {
245273
]);
246274
});
247275

248-
test("a tool under its default connector name runs through its source", async () => {
276+
test("the model sees each source tool by its source name, with its -", async () => {
277+
await startHarness();
278+
const sessionId = `connector-${randomUUID()}`;
279+
await sessions.startSession(sessionId, "tools");
280+
assert.deepEqual((await answerLines(sessionId, 1)).sort(), [
281+
"approved_payout",
282+
"create_payout",
283+
"echo_text",
284+
"fake-ledger_get_balance",
285+
"fake_ledger_get_balance",
286+
"get_balance",
287+
"post_note",
288+
"post_note_retried",
289+
]);
290+
});
291+
292+
test("two tools that differ only by - and _ each run through their own name and policy", async () => {
249293
const harness = await startHarness();
250294
const sessionId = `connector-${randomUUID()}`;
251-
await sessions.startSession(sessionId, calls([["fake_ledger_get_balance", { accountId: "a-2" }]], "default-name"));
252-
const [record] = await heldRecords(harness, sessionId, "default-name", 1);
253-
assert.equal(record?.toolName, "fake-ledger_get_balance");
254-
assert.equal(record?.status, "succeeded");
255-
assert.deepEqual(await answerLines(sessionId, 1), ['fake_ledger_get_balance: {"accountId":"a-2","amount":100}']);
295+
await sessions.startSession(
296+
sessionId,
297+
calls(
298+
[
299+
["fake-ledger_get_balance", { accountId: "a-2" }],
300+
["fake_ledger_get_balance", { accountId: "a-3" }],
301+
],
302+
"default-name",
303+
),
304+
);
305+
// Only the default name's toolPolicies entry requires approval.
306+
const interrupt = await sessions.waitForPendingInterrupt(sessionId);
307+
assert.equal(interrupt.toolName, "fake-ledger_get_balance");
308+
assert.equal(await sessions.resolveInterrupt(sessionId, interrupt.interruptId, true), true);
309+
const records = await heldRecords(harness, sessionId, "default-name", 2);
310+
assert.deepEqual(
311+
records.map((record) => [record.toolName, record.status]),
312+
[
313+
["fake-ledger_get_balance", "succeeded"],
314+
["fake_ledger_get_balance", "succeeded"],
315+
],
316+
);
317+
assert.deepEqual(await answerLines(sessionId, 1), [
318+
'fake-ledger_get_balance: {"accountId":"a-2","amount":100}',
319+
'fake_ledger_get_balance: {"accountId":"a-3","amount":100}',
320+
]);
256321
});
257322

258323
test("an approved Mutation runs once after its approval", async () => {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
section: Fixed
3+
---
4+
- TypeScript OpenAI Agents adapter and the TypeScript **dex-agents-openai-sdk**:
5+
the model sees each tool source's tool, such as a Connector tool, by its
6+
source name, as the Python adapter does. The OpenAI Agents SDK's `tool()`
7+
used to turn every `-` into `_`, so the model saw
8+
`hacker_news_daily_list_story_ids` for `hacker-news-daily_list_story_ids`,
9+
and two tools that differ only by `-` and `_` could not both be offered.
10+
Only a character the OpenAI API refuses, one other than a letter, digit,
11+
`_`, or `-`, still reaches the model as `_`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
section: Breaking
3+
---
4+
- TypeScript OpenAI Agents adapter and the TypeScript **dex-agents-openai-sdk**:
5+
a turn that waits on a call of a source tool whose name has `-`, such as one
6+
waiting for approval, while its Worker upgrades fails when it resumes. Its
7+
saved run state names the tool with `_`, the name the model saw before this
8+
release, and the SDK no longer finds that tool. New turns are not affected.

0 commit comments

Comments
 (0)