diff --git a/packages/translate/__tests__/composition/request/tool-names_test.ts b/packages/translate/__tests__/composition/request/tool-names_test.ts index 2ce4538ba7..6190eb8164 100644 --- a/packages/translate/__tests__/composition/request/tool-names_test.ts +++ b/packages/translate/__tests__/composition/request/tool-names_test.ts @@ -109,3 +109,8 @@ test.each([undefined, 'files'])('allocates distinct names per callable kind in n assertEquals([...selected.context.customToolNames], [second]); assertEquals(selected.context.originalToolNames.get(second), { name: 'edit', ...(namespace === undefined ? {} : { namespace }) }); }); + +test('a request declaring one callable identity twice is refused before allocation', () => { + const tool: IRInputTool = { type: 'function', namespace: 'agents', name: 'wait', parameters: { type: 'object' } }; + assertThrows(() => allocateToolNames(request([tool, tool]), []), TranslateInputException, 'repeated declarations'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts index 390c27f49d..6926df0a01 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts @@ -202,3 +202,33 @@ for (const allowed of [false, true]) { : { type: 'function', function: { name: expected } }); }); } + +test.each(['chat', 'messages'] as const)('redeclared callables reach %s once with the latest declaration in the first slot', async target => { + const tools = (description: string): OpenAIResponsesTool[] => [ + { type: 'namespace', name: 'agents', description: '', tools: [{ type: 'function', name: 'wait', parameters: { type: 'object' } }] }, + { type: 'function', name: 'exec', description, parameters: { type: 'object' } }, + ]; + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, + tools: [...tools('first'), { type: 'function', name: 'read', parameters: { type: 'object' } }], + input: [ + { type: 'additional_tools', role: 'developer', tools: tools('second') }, + { type: 'message', role: 'user', content: 'hi' }, + { type: 'additional_tools', role: 'developer', tools: tools('latest') }, + ], + tool_choice: { type: 'allowed_tools', mode: 'auto', tools: [{ type: 'namespace', name: 'agents' }] }, + }; + const codec = createTestSidecarCodec(); + if (target === 'chat') { + const { target: wire } = await buildChat(payload, codec); + assertEquals(wire.tools?.map(tool => tool.type === 'function' ? [tool.function.name, tool.function.description] : []), [ + ['agents_wait', undefined], ['exec', 'latest'], ['read', undefined], + ]); + assertEquals(wire.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'auto', tools: [{ type: 'function', function: { name: 'agents_wait' } }] } }); + } else { + const { target: wire } = await buildMessages({ ...payload, tool_choice: 'auto' }, codec); + assertEquals(wire.tools?.map(tool => 'name' in tool ? [tool.name, 'description' in tool ? tool.description : undefined] : []), [ + ['agents_wait', undefined], ['exec', 'latest'], ['read', undefined], + ]); + } +}); diff --git a/packages/translate/src/composition/request/tool-names.ts b/packages/translate/src/composition/request/tool-names.ts index e121ead99f..8685765188 100644 --- a/packages/translate/src/composition/request/tool-names.ts +++ b/packages/translate/src/composition/request/tool-names.ts @@ -28,6 +28,13 @@ export const allocateToolNames = ( request: IRInputRequestHeader, inputTurns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], ): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]; readonly context: ToolNameContext } => { + const declared = new Set(); + for (const tool of request.tools ?? []) { + const key = callableKey(tool); + // One identity allocates one name, and these wires reject a repeated declaration name. + if (declared.has(key)) throw new TranslateInputException(`Cannot translate repeated declarations of tool '${tool.name}'.`); + declared.add(key); + } const callables = [ ...(request.tools ?? []), ...inputTurns.flatMap(turn => turn.kind === 'restored' ? [] : turn.items.flatMap(item => diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 289de14082..55dccd9e25 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -54,7 +54,8 @@ export interface IRInputOutputFormat { // All export interface IRInputRequestHeader { readonly settings: IRInputSettings; // All - // Absent and empty are the same thing: a request that declares no tool. + // Absent and empty are the same thing: a request that declares no tool. Each + // callable identity appears once; a source that allows redeclaration resolves it. readonly tools?: readonly IRInputTool[]; // All readonly tool_choice?: IRInputToolChoice; // All } diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 37f21fa612..b8a494a84d 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -228,14 +228,10 @@ const selectorOf = (payload: CanonicalOpenAIResponsesPayload, selector: Record key !== 'type' && key !== 'name')) { throw new TranslateInputException('Cannot translate namespace selector extensions.'); } - const groups = [ - ...(payload.tools ?? []), - ...payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []), - ].flatMap(tool => tool.type === 'namespace' && tool.name === selector.name ? [tool] : []); - if (groups.length === 0) throw new TranslateInputException(`Cannot translate group selector '${String(selector.name)}' without a matching declaration.`); - return groups.flatMap(group => group.tools.flatMap(child => child.type === 'function' || child.type === 'custom' - ? [{ type: child.type, name: child.name, namespace: group.name } as IRInputToolSelector] - : [])); + if (!toolInventories(payload).some(tool => tool.type === 'namespace' && tool.name === selector.name)) { + throw new TranslateInputException(`Cannot translate group selector '${String(selector.name)}' without a matching declaration.`); + } + return declarations.flatMap(({ type, namespace, name }) => namespace === selector.name ? [{ type, namespace, name }] : []); }; const toolChoiceToIR = ( @@ -296,14 +292,22 @@ const toolGroupToIR = (tool: OpenAIResponsesTool): IRInputTool[] => { // A request can defer declarations to an inventory item inside the // conversation; those are tools like any other, not things the model said. -const deferredInventories = (payload: CanonicalOpenAIResponsesPayload): OpenAIResponsesTool[] => - payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []); +const toolInventories = (payload: CanonicalOpenAIResponsesPayload): OpenAIResponsesTool[] => [ + ...(payload.tools ?? []), + ...payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []), +]; + +// Codex declares a namespace in `tools`, again in `additional_tools`, and once +// more after each compaction, so one callable can arrive many times. The latest +// declaration is the caller's current statement of it; it takes the slot of the +// first, which keeps the declared prefix stable for prompt caching. +const latestDeclarations = (tools: readonly IRInputTool[]): IRInputTool[] => + [...new Map(tools.map(tool => [JSON.stringify([tool.type, tool.namespace, tool.name]), tool])).values()]; const openaiResponsesToolsToIR = (payload: CanonicalOpenAIResponsesPayload): Pick => { - const declared = payload.tools?.flatMap(toolGroupToIR) ?? []; - const deferred = deferredInventories(payload).flatMap(toolGroupToIR); - const tools = declared.length === 0 && deferred.length === 0 ? undefined : [...declared, ...deferred]; - const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, payload.tool_choice, tools ?? []); + const declarations = latestDeclarations(toolInventories(payload).flatMap(toolGroupToIR)); + const tools = declarations.length === 0 ? undefined : declarations; + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, payload.tool_choice, declarations); return { ...(tools === undefined ? {} : { tools }), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }),