From 41f8736a8bb077dd38472c3ea72cce4d55115891 Mon Sep 17 00:00:00 2001 From: Pratham-Mishra04 Date: Fri, 10 Jul 2026 21:56:30 +0530 Subject: [PATCH] feat: adds addition_tools message type support --- .../openai/tool_search_roundtrip_test.go | 50 ++++++++++++ core/schemas/responses.go | 81 ++++++++++--------- core/schemas/responses_test.go | 41 +++++++++- .../workspace/logs/sheets/logDetailView.tsx | 77 +++++++++++++++--- ui/lib/types/logs.ts | 5 +- 5 files changed, 207 insertions(+), 47 deletions(-) diff --git a/core/providers/openai/tool_search_roundtrip_test.go b/core/providers/openai/tool_search_roundtrip_test.go index 4bb43eaafee..39ee61ca44c 100644 --- a/core/providers/openai/tool_search_roundtrip_test.go +++ b/core/providers/openai/tool_search_roundtrip_test.go @@ -1,6 +1,7 @@ package openai import ( + "fmt" "testing" "github.com/tidwall/gjson" @@ -51,3 +52,52 @@ func TestResponsesInputRoundTripsToolSearchItems(t *testing.T) { } t.Logf("round-trip OK:\n%s", out) } + +// TestResponsesInputRoundTripsAdditionalToolsItems reproduces the codex +// first request for code-mode models (e.g. gpt-5.6-sol), whose +// `additional_tools` input item previously had its nested tools re-serialized +// through the mcp_list_tools shape — dropping every `tools[].type` +// discriminator and making OpenAI reject with "Missing required parameter: +// 'input[0].tools[0].type'" — and verifies the item now round-trips unchanged. +func TestResponsesInputRoundTripsAdditionalToolsItems(t *testing.T) { + input := []byte(`[ + {"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"apply_patch","description":"Apply a patch"},{"type":"function","name":"shell","description":"Runs a shell command","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}},{"type":"namespace","name":"repo_tools","description":"Repository helper tools","tools":[{"type":"function","name":"open_file","description":"Open a file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},{"type":"function","name":"list_files","description":"List files","parameters":{"type":"object","properties":{"dir":{"type":"string"}},"required":["dir"]}}]}]}, + {"role":"user","content":[{"type":"input_text","text":"Reply exactly with OK."}]} + ]`) + + var in OpenAIResponsesRequestInput + if err := in.UnmarshalJSON(input); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if got := len(in.OpenAIResponsesRequestInputArray); got != 2 { + t.Fatalf("expected 2 input items, got %d", got) + } + + out, err := in.MarshalJSON() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // Every nested tools[].type must survive (OpenAI requires them). + for i, want := range []string{"custom", "function", "namespace"} { + if got := gjson.GetBytes(out, fmt.Sprintf("0.tools.%d.type", i)).String(); got != want { + t.Fatalf("additional_tools.tools[%d].type lost: %q (full: %s)", i, got, gjson.GetBytes(out, "0.tools").Raw) + } + } + // Function parameters and the namespace's nested tool list must survive. + if !gjson.GetBytes(out, "0.tools.1.parameters").IsObject() { + t.Fatalf("additional_tools function parameters lost: %s", gjson.GetBytes(out, "0.tools.1").Raw) + } + if got := gjson.GetBytes(out, "0.tools.2.tools.#").Int(); got != 2 { + t.Fatalf("additional_tools namespace nested tools lost: %s", gjson.GetBytes(out, "0.tools.2").Raw) + } + // The typed mcp_list_tools shape must not bleed in. + if gjson.GetBytes(out, "0.tools.0.input_schema").Exists() { + t.Fatalf("mcp_list_tools shape leaked into additional_tools: %s", gjson.GetBytes(out, "0.tools.0").Raw) + } + // The ordinary user message must still parse/serialize normally. + if got := gjson.GetBytes(out, "1.role").String(); got != "user" { + t.Fatalf("plain user message broke: %q", got) + } + t.Logf("round-trip OK:\n%s", out) +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 69d2f0ac27c..b0e1623f066 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -1047,13 +1047,16 @@ const ( ResponsesMessageTypeItemReference ResponsesMessageType = "item_reference" ResponsesMessageTypeRefusal ResponsesMessageType = "refusal" ResponsesMessageTypeCompaction ResponsesMessageType = "compaction" - // Codex deferred-tool discovery (tool_search). OpenAI's Responses API - // supports these item types natively; Bifrost preserves them verbatim - // because its typed schema doesn't model them (the call's `arguments` is a - // JSON object — unlike function_call's string — and the output carries a - // `tools` array). See ResponsesMessage's (Un)MarshalJSON. + // Codex deferred-tool discovery (tool_search) and code-mode tool + // declarations (additional_tools). OpenAI's Responses API supports these + // item types natively; Bifrost preserves them verbatim because its typed + // schema doesn't model them (tool_search_call's `arguments` is a JSON + // object — unlike function_call's string — and tool_search_output / + // additional_tools carry `tools` arrays whose entries don't fit any typed + // tool shape). See ResponsesMessage's (Un)MarshalJSON. ResponsesMessageTypeToolSearchCall ResponsesMessageType = "tool_search_call" ResponsesMessageTypeToolSearchOutput ResponsesMessageType = "tool_search_output" + ResponsesMessageTypeAdditionalTools ResponsesMessageType = "additional_tools" ResponsesMessageTypeAdvisorCall ResponsesMessageType = "advisor_call" // Anthropic advisor server tool (server_tool_use + advisor_tool_result) ) @@ -1084,43 +1087,49 @@ type ResponsesMessage struct { // gpt-oss models include only reasoning_text content blocks in a message, while other openai models include summaries+encrypted_content *ResponsesReasoning - // rawToolSearch preserves codex `tool_search_call` / `tool_search_output` - // items verbatim. OpenAI's Responses API accepts these natively, but - // Bifrost's typed schema doesn't model them (the call's `arguments` is a - // JSON object — unlike function_call's string — and the output carries a - // `tools` array). Rather than fail to deserialize the whole input array or - // drop/mangle these items, we round-trip the original bytes unchanged. + // rawPreserved preserves codex `tool_search_call` / `tool_search_output` / + // `additional_tools` items verbatim. OpenAI's Responses API accepts these + // natively, but Bifrost's typed schema doesn't model them: + // tool_search_call's `arguments` is a JSON object — unlike function_call's + // string — and tool_search_output / additional_tools carry `tools` arrays + // whose entries (per-entry `type` discriminators, function parameters, + // nested namespace tool lists) don't fit any typed tool shape; a typed + // decode promotes them into the embedded mcp_list_tools fields and strips + // required fields. Rather than fail to deserialize the whole input array + // or drop/mangle these items, we round-trip the original bytes unchanged. // Set by UnmarshalJSON, emitted by MarshalJSON; nil for every other type. - rawToolSearch []byte + rawPreserved []byte } -// isToolSearchItem reports whether t is a codex tool_search item type, which -// Bifrost preserves verbatim rather than modelling field-by-field. -func isToolSearchItem(t string) bool { +// isRawPreservedItem reports whether t is an item type that Bifrost preserves +// verbatim rather than modelling field-by-field (see rawPreserved). +func isRawPreservedItem(t string) bool { return t == string(ResponsesMessageTypeToolSearchCall) || - t == string(ResponsesMessageTypeToolSearchOutput) -} - -// UnmarshalJSON preserves codex tool_search items verbatim (see rawToolSearch) -// and otherwise normalizes function/tool-call arguments before decoding the rest -// of the item. OpenAI's Responses API serializes `function_call` `arguments` as -// a JSON string, but `tool_search_call` items serialize `arguments` as a JSON -// object — e.g. {} while in_progress and {"query":"...","limit":10} when -// completed. The embedded ResponsesToolMessage.Arguments field is a *string, so -// an object value makes a plain decode fail with "Mismatch type string with -// value object", which silently drops the item mid-stream and hangs streaming -// clients. We shadow `arguments` as raw JSON, decode everything else as usual, -// then store the canonical stringified form. + t == string(ResponsesMessageTypeToolSearchOutput) || + t == string(ResponsesMessageTypeAdditionalTools) +} + +// UnmarshalJSON preserves codex tool_search/additional_tools items verbatim +// (see rawPreserved) and otherwise normalizes function/tool-call arguments +// before decoding the rest of the item. OpenAI's Responses API serializes +// `function_call` `arguments` as a JSON string, but `tool_search_call` items +// serialize `arguments` as a JSON object — e.g. {} while in_progress and +// {"query":"...","limit":10} when completed. The embedded +// ResponsesToolMessage.Arguments field is a *string, so an object value makes +// a plain decode fail with "Mismatch type string with value object", which +// silently drops the item mid-stream and hangs streaming clients. We shadow +// `arguments` as raw JSON, decode everything else as usual, then store the +// canonical stringified form. func (m *ResponsesMessage) UnmarshalJSON(data []byte) error { // Clear the receiver first so a reused instance never retains a stale - // rawToolSearch (or other fields) from a prior decode — unmarshalling a - // non-tool-search payload must not leave preserved bytes that MarshalJSON + // rawPreserved (or other fields) from a prior decode — unmarshalling a + // non-preserved payload must not leave preserved bytes that MarshalJSON // would then re-emit. *m = ResponsesMessage{} - if t := gjson.GetBytes(data, "type").String(); isToolSearchItem(t) { + if t := gjson.GetBytes(data, "type").String(); isRawPreservedItem(t) { mt := ResponsesMessageType(t) m.Type = &mt - m.rawToolSearch = append([]byte(nil), data...) + m.rawPreserved = append([]byte(nil), data...) return nil } @@ -1147,11 +1156,11 @@ func (m *ResponsesMessage) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON re-emits preserved tool_search items verbatim and defers every -// other item type to the default (sorted-key) struct encoding. +// MarshalJSON re-emits preserved items verbatim and defers every other item +// type to the default (sorted-key) struct encoding. func (m ResponsesMessage) MarshalJSON() ([]byte, error) { - if m.rawToolSearch != nil { - return m.rawToolSearch, nil + if m.rawPreserved != nil { + return m.rawPreserved, nil } type alias ResponsesMessage return MarshalSorted(alias(m)) diff --git a/core/schemas/responses_test.go b/core/schemas/responses_test.go index 28692507339..b6f78e3d460 100644 --- a/core/schemas/responses_test.go +++ b/core/schemas/responses_test.go @@ -232,7 +232,7 @@ func TestResponsesMessageToolCallArguments(t *testing.T) { // Codex's request (which enables the `tool_search` tool). These are the exact // frames that triggered the production "Mismatch type string with value // object" failure. tool_search items are preserved verbatim (see - // rawToolSearch), so the item must decode without error and re-encode + // rawPreserved), so the item must decode without error and re-encode // byte-identically, object-form arguments included. t.Run("real tool_search_call frames from openai", func(t *testing.T) { items := map[string]string{ @@ -262,6 +262,45 @@ func TestResponsesMessageToolCallArguments(t *testing.T) { }) } +// TestResponsesMessagePreservesAdditionalTools verifies that codex +// `additional_tools` input items (sent for code-mode models such as +// gpt-5.6-sol) round-trip byte-identically. These items carry a `tools` array +// whose entries have their own `type` discriminators (custom / function / +// namespace with nested tool lists); a typed decode promotes the array into +// the embedded mcp_list_tools fields and strips `type`, making OpenAI reject +// the forwarded request with "Missing required parameter: +// 'input[0].tools[0].type'". +func TestResponsesMessagePreservesAdditionalTools(t *testing.T) { + raw := `{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"apply_patch","description":"Apply a patch"},{"type":"function","name":"shell","description":"Runs a shell command","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}},{"type":"namespace","name":"repo_tools","description":"Repository helper tools","tools":[{"type":"function","name":"open_file","description":"Open a file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}]}]}` + + var msg ResponsesMessage + if err := Unmarshal([]byte(raw), &msg); err != nil { + t.Fatalf("unmarshal additional_tools item: %v", err) + } + if msg.Type == nil || *msg.Type != ResponsesMessageTypeAdditionalTools { + t.Fatalf("expected additional_tools item, got %#v", msg.Type) + } + encoded, err := MarshalSorted(msg) + if err != nil { + t.Fatalf("marshal preserved additional_tools item: %v", err) + } + if string(encoded) != raw { + t.Fatalf("expected item to round-trip verbatim\nwant: %s\ngot: %s", raw, encoded) + } + + // A reused receiver must not leak preserved bytes into the next decode. + if err := Unmarshal([]byte(`{"type":"message","role":"user","content":"hi"}`), &msg); err != nil { + t.Fatalf("unmarshal follow-up message: %v", err) + } + encoded, err = MarshalSorted(msg) + if err != nil { + t.Fatalf("marshal follow-up message: %v", err) + } + if strings.Contains(string(encoded), "additional_tools") { + t.Fatalf("expected reused receiver to drop preserved bytes, got %s", encoded) + } +} + func TestResponsesMessagePreservesOpenAIPhase(t *testing.T) { raw := []byte(`{"id":"msg_123","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"}`) diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index edaafc5411e..8622167e8f6 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -238,6 +238,8 @@ const getResponsesRole = (msg: ResponsesMessage): MessageRole => { msg.type && (msg.type.endsWith("_call") || msg.type.endsWith("_call_output") || + msg.type === "tool_search_output" || + msg.type === "additional_tools" || msg.type === "mcp_list_tools" || msg.type === "mcp_approval_request" || msg.type === "mcp_approval_responses") @@ -261,6 +263,37 @@ const isReasoningResponsesMessage = (m: ResponsesMessage): boolean => m.type === // Streaming providers can emit a single logical assistant turn (or reasoning // item) as many small messages. Collapse adjacent ones so the UI shows one // bubble per turn instead of N "1 line" bubbles. +// Expands namespace tool declarations into their callable children +// (`namespace.tool` names), leaving plain declarations untouched. +const flattenDeclaredTools = (tools: any[]): any[] => + tools.flatMap((tool) => + tool?.type === "namespace" && Array.isArray(tool.tools) + ? (tool.tools as any[]).map((nested) => ({ ...nested, name: `${tool.name ?? "namespace"}.${nested?.name ?? ""}` })) + : [tool], + ); + +// Later declarations of the same tool replace earlier ones — a conversation +// history can carry multiple `additional_tools` items, each a point-in-time +// update, so the effective tool set is last-write-wins by name. Unnamed +// declarations are kept as-is. +const dedupeDeclaredTools = (tools: any[]): any[] => { + const byName = new Map(); + const out: any[] = []; + for (const tool of tools) { + const name = tool?.name ?? tool?.function?.name; + if (typeof name === "string" && name) { + const idx = byName.get(name); + if (idx !== undefined) { + out[idx] = tool; + continue; + } + byName.set(name, out.length); + } + out.push(tool); + } + return out; +}; + const coalesceResponsesMessages = (msgs: ResponsesMessage[]): ResponsesMessage[] => { const out: ResponsesMessage[] = []; for (const m of msgs) { @@ -621,10 +654,18 @@ export function LogDetailView({ }) : null; + // Tools can also be declared inside Responses input items instead of the + // top-level tools param (codex code-mode models send an `additional_tools` + // input item with the tool definitions, including namespace groupings). + const inputDeclaredToolEntries = (log.responses_input_history ?? []) + .filter((m) => m.type === "additional_tools" && Array.isArray(m.tools)) + .flatMap((m) => m.tools as any[]); + const inputDeclaredTools = flattenDeclaredTools(inputDeclaredToolEntries); + const declaredTools = dedupeDeclaredTools([...flattenDeclaredTools((log.params?.tools as any[]) ?? []), ...inputDeclaredTools]); let toolsParameter = null; - if (log.params?.tools) { + if (declaredTools.length) { try { - toolsParameter = JSON.stringify(log.params.tools, null, 2); + toolsParameter = JSON.stringify(declaredTools, null, 2); } catch {} } @@ -901,7 +942,7 @@ export function LogDetailView({ ) : ( )} @@ -1614,9 +1655,9 @@ export function LogDetailView({ {showTabs && !isPassthrough && !log.list_models_output && ( Tools - {log.params?.tools?.length ? ( + {declaredTools.length ? ( - {log.params.tools.length} + {declaredTools.length} ) : null} @@ -2065,7 +2106,14 @@ export function LogDetailView({ ? msg.name : msg.type === "function_call_output" && msg.call_id ? msg.call_id - : msg.type || undefined; + : Array.isArray(msg.tools) + ? (() => { + const callable = flattenDeclaredTools(msg.tools).length; + return callable !== msg.tools.length + ? `${msg.type} · ${msg.tools.length} declarations · ${callable} callable tools` + : `${msg.type} · ${msg.tools.length} tool${msg.tools.length === 1 ? "" : "s"}`; + })() + : msg.type || undefined; } const usePlainText = role === "user" || role === "assistant"; return ( @@ -2113,6 +2161,10 @@ export function LogDetailView({ text={typeof msg.output === "string" ? msg.output : JSON.stringify(msg.output, null, 2)} preview={3} /> + ) : Array.isArray(msg.tools) && msg.tools.length > 0 ? ( + + ) : Array.isArray(msg.tools) ? ( +
No tools declared
) : (
No content
)} @@ -2240,7 +2292,14 @@ export function LogDetailView({ {toolsParameter ? (
- {log.params?.tools?.length ?? 0} tools exposed to the model + {declaredTools.length} tools exposed to the model + {inputDeclaredTools.length ? ( + <> + {" "} + · {inputDeclaredTools.length} via input items + {inputDeclaredTools.length !== inputDeclaredToolEntries.length ? " (namespaces expanded)" : ""} + + ) : null} {(log.params as any)?.tool_choice != null ? ( <> {" "} @@ -2250,8 +2309,8 @@ export function LogDetailView({ ) : null}
- {(log.params?.tools as any[]).map((tool, i) => { - const name = tool?.function?.name ?? tool?.name ?? `tool_${i}`; + {declaredTools.map((tool, i) => { + const name = tool?.name ?? tool?.function?.name ?? `tool_${i}`; const description = tool?.function?.description ?? tool?.description ?? ""; const schema = tool?.function?.parameters ?? tool?.input_schema ?? tool?.parameters ?? null; const schemaJson = schema != null ? JSON.stringify(schema, null, 2) : ""; diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts index a831c04712c..5d7884c0d6b 100644 --- a/ui/lib/types/logs.ts +++ b/ui/lib/types/logs.ts @@ -843,7 +843,10 @@ export type ResponsesMessageType = | "mcp_approval_responses" | "reasoning" | "item_reference" - | "refusal"; + | "refusal" + | "tool_search_call" + | "tool_search_output" + | "additional_tools"; // Content block types for responses export type ResponsesMessageContentBlockType =