Skip to content

Commit 65f9da5

Browse files
committed
fix(agents): guard OpenAI transport tool descriptors
1 parent 9cb052c commit 65f9da5

2 files changed

Lines changed: 125 additions & 5 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4173,6 +4173,65 @@ describe("openai transport stream", () => {
41734173
expect(first.tools).toEqual(second.tools);
41744174
});
41754175

4176+
it("materializes OpenAI transport tool descriptors before request conversion", () => {
4177+
const responsesModel = {
4178+
id: "gpt-5.4",
4179+
name: "GPT-5.4",
4180+
api: "openai-responses",
4181+
provider: "openai",
4182+
baseUrl: "https://api.openai.com/v1",
4183+
reasoning: true,
4184+
input: ["text"],
4185+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4186+
contextWindow: 200000,
4187+
maxTokens: 8192,
4188+
} satisfies Model<"openai-responses">;
4189+
const completionsModel = {
4190+
...responsesModel,
4191+
api: "openai-completions",
4192+
} satisfies Model<"openai-completions">;
4193+
const toolWithUnreadableDescription = {
4194+
name: "safe_lookup",
4195+
get description() {
4196+
throw new Error("description exploded");
4197+
},
4198+
parameters: { type: "object", properties: {}, additionalProperties: false },
4199+
};
4200+
const toolWithUnreadableName = {
4201+
get name() {
4202+
throw new Error("name exploded");
4203+
},
4204+
description: "Skip me",
4205+
parameters: { type: "object", properties: {}, additionalProperties: false },
4206+
};
4207+
4208+
const responsesParams = buildOpenAIResponsesParams(
4209+
responsesModel,
4210+
{
4211+
systemPrompt: "system",
4212+
messages: [],
4213+
tools: [toolWithUnreadableDescription, toolWithUnreadableName],
4214+
} as never,
4215+
undefined,
4216+
) as { tools?: Array<{ name?: string; description?: string }> };
4217+
const completionsParams = buildOpenAICompletionsParams(
4218+
completionsModel,
4219+
{
4220+
systemPrompt: "system",
4221+
messages: [],
4222+
tools: [toolWithUnreadableDescription, toolWithUnreadableName],
4223+
} as never,
4224+
undefined,
4225+
) as { tools?: Array<{ function?: { name?: string; description?: string } }> };
4226+
4227+
expect(responsesParams.tools).toHaveLength(1);
4228+
expect(responsesParams.tools?.[0]).toMatchObject({ name: "safe_lookup" });
4229+
expect(responsesParams.tools?.[0]?.description).toBe("");
4230+
expect(completionsParams.tools).toHaveLength(1);
4231+
expect(completionsParams.tools?.[0]?.function).toMatchObject({ name: "safe_lookup" });
4232+
expect(completionsParams.tools?.[0]?.function?.description).toBe("");
4233+
});
4234+
41764235
it("falls back to strict:false when a native OpenAI tool schema is not strict-compatible", () => {
41774236
const params = buildOpenAIResponsesParams(
41784237
{

src/agents/openai-transport-stream.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & {
113113
[OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]?: OpenAIResponsesReasoningReplayMetadata;
114114
};
115115
type ResponsesClientLike = ReturnType<typeof createOpenAIResponsesClient>;
116+
type OpenAITransportTool = NonNullable<Context["tools"]>[number];
117+
type MaterializedOpenAITransportTool = {
118+
name: string;
119+
description: string;
120+
parameters: unknown;
121+
sourceIndex: number;
122+
};
116123

117124
type BaseStreamOptions = {
118125
temperature?: number;
@@ -1256,11 +1263,12 @@ function convertResponsesTools(
12561263
model: OpenAIModeModel,
12571264
options?: { strict?: boolean | null },
12581265
): FunctionTool[] {
1259-
const strict = resolveOpenAIStrictToolFlagWithDiagnostics(tools, options?.strict, {
1266+
const materializedTools = materializeOpenAITransportTools(tools);
1267+
const strict = resolveOpenAIStrictToolFlagWithDiagnostics(materializedTools, options?.strict, {
12601268
transport: "responses",
12611269
model,
12621270
});
1263-
return sortTransportToolsByName(tools).map((tool): FunctionTool => {
1271+
return sortTransportToolsByName(materializedTools).map((tool): FunctionTool => {
12641272
const result = {
12651273
type: "function" as const,
12661274
name: tool.name,
@@ -1279,7 +1287,7 @@ function convertResponsesTools(
12791287
}
12801288

12811289
function resolveOpenAIStrictToolFlagWithDiagnostics(
1282-
tools: NonNullable<Context["tools"]>,
1290+
tools: readonly { name?: string; parameters: unknown }[],
12831291
strictSetting: boolean | null | undefined,
12841292
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
12851293
): boolean | undefined {
@@ -3263,8 +3271,9 @@ function convertTools(
32633271
compat: ReturnType<typeof getCompat>,
32643272
model: OpenAIModeModel,
32653273
) {
3274+
const materializedTools = materializeOpenAITransportTools(tools);
32663275
const strict = resolveOpenAIStrictToolFlagWithDiagnostics(
3267-
tools,
3276+
materializedTools,
32683277
resolveOpenAIStrictToolSetting(model, {
32693278
transport: "stream",
32703279
supportsStrictMode: compat?.supportsStrictMode,
@@ -3274,7 +3283,7 @@ function convertTools(
32743283
model,
32753284
},
32763285
);
3277-
return sortTransportToolsByName(tools).map((tool) => {
3286+
return sortTransportToolsByName(materializedTools).map((tool) => {
32783287
const functionTool: {
32793288
name: string;
32803289
description: string | undefined;
@@ -3299,6 +3308,58 @@ function convertTools(
32993308
});
33003309
}
33013310

3311+
function readOpenAITransportToolField<TField extends keyof OpenAITransportTool>(
3312+
tool: OpenAITransportTool,
3313+
field: TField,
3314+
): { ok: true; value: OpenAITransportTool[TField] } | { ok: false } {
3315+
try {
3316+
return { ok: true, value: tool[field] };
3317+
} catch {
3318+
return { ok: false };
3319+
}
3320+
}
3321+
3322+
function materializeOpenAITransportTools(
3323+
tools: NonNullable<Context["tools"]>,
3324+
): MaterializedOpenAITransportTool[] {
3325+
const materializedTools: MaterializedOpenAITransportTool[] = [];
3326+
const skippedTools: string[] = [];
3327+
tools.forEach((tool, sourceIndex) => {
3328+
const nameRead = readOpenAITransportToolField(tool, "name");
3329+
const name = nameRead.ok && typeof nameRead.value === "string" ? nameRead.value : "";
3330+
const toolLabel = name || `tool[${sourceIndex}]`;
3331+
if (!name) {
3332+
skippedTools.push(toolLabel);
3333+
return;
3334+
}
3335+
const parametersRead = readOpenAITransportToolField(tool, "parameters");
3336+
if (!parametersRead.ok) {
3337+
skippedTools.push(toolLabel);
3338+
return;
3339+
}
3340+
const descriptionRead = readOpenAITransportToolField(tool, "description");
3341+
materializedTools.push({
3342+
name,
3343+
description:
3344+
descriptionRead.ok && typeof descriptionRead.value === "string"
3345+
? descriptionRead.value
3346+
: "",
3347+
parameters: parametersRead.value,
3348+
sourceIndex,
3349+
});
3350+
});
3351+
if (skippedTools.length > 0) {
3352+
log.warn(
3353+
`OpenAI transport skipped ${skippedTools.length} unreadable tool descriptor${skippedTools.length === 1 ? "" : "s"} before request conversion`,
3354+
{
3355+
tools: skippedTools.slice(0, 10),
3356+
omitted: Math.max(0, skippedTools.length - 10),
3357+
},
3358+
);
3359+
}
3360+
return materializedTools;
3361+
}
3362+
33023363
function compareTransportToolText(left: string | undefined, right: string | undefined): number {
33033364
const leftText = left ?? "";
33043365
const rightText = right ?? "";

0 commit comments

Comments
 (0)