diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 43f86ad8..3da52759 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -278,6 +278,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } reporter.MarkFirstResponseByte() payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) + payload = restoreCodexMultiAgentToolNamespace(payload, originalPayload, clientBody) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) payload = helps.RestoreCodexMultiAgentV2Response(payload, optimizeMultiAgentV2) diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 17bbdcbe..b0855a60 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -43,6 +43,21 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) } } +func TestRestoreCodexMultiAgentToolNamespace(t *testing.T) { + request := []byte(`{"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"wait_agent"},{"type":"function","name":"close_agent"}]}]}]}`) + input := []byte(`{"type":"response.output_item.done","item":{"type":"collab_agent_tool_call","name":"wait_agent"},"response":{"output":[{"type":"function_call","name":"close_agent"}]}}`) + got := restoreCodexMultiAgentToolNamespace(input, request) + if name := gjson.GetBytes(got, "item.name").String(); name != "wait_agent" { + t.Fatalf("item.name = %q, want bare child name; payload=%s", name, got) + } + if namespace := gjson.GetBytes(got, "item.namespace").String(); namespace != "multi_agent_v1" { + t.Fatalf("item.namespace = %q, want multi_agent_v1; payload=%s", namespace, got) + } + if namespace := gjson.GetBytes(got, "response.output.0.namespace").String(); namespace != "multi_agent_v1" { + t.Fatalf("response.output.0.namespace = %q, want multi_agent_v1; payload=%s", namespace, got) + } +} + func TestBuildCodexWebsocketRequestBodySanitizesOverlongInputItemIDs(t *testing.T) { longReasoningItemID := "rs_" + strings.Repeat("a", 64) longCallItemID := strings.Repeat("grok-call-item-", 6) diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go index fef25833..1ee34adf 100644 --- a/internal/runtime/executor/codex_websockets_request.go +++ b/internal/runtime/executor/codex_websockets_request.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "net/http" "strings" @@ -258,6 +259,102 @@ func headerValueCaseInsensitive(headers http.Header, key string) string { return "" } +// restoreCodexMultiAgentToolNamespace repairs response tool calls where the +// model omits a namespace that was declared in the request. Bare child names +// are restored only when they identify exactly one namespace tool. +func restoreCodexMultiAgentToolNamespace(payload []byte, requestPayload ...[]byte) []byte { + if len(payload) == 0 { + return payload + } + namespaces := codexNamespaceChildren(requestPayload) + if len(namespaces) == 0 { + return payload + } + var value any + if err := json.Unmarshal(payload, &value); err != nil { + return payload + } + changed := false + var rewrite func(any) + rewrite = func(node any) { + switch current := node.(type) { + case map[string]any: + name, hasName := current["name"].(string) + _, hasNamespace := current["namespace"] + if hasName && !hasNamespace { + name = strings.TrimSpace(name) + if namespace, ok := namespaces[name]; ok && namespace != "" { + current["namespace"] = namespace + changed = true + } + } + for _, child := range current { + rewrite(child) + } + case []any: + for _, child := range current { + rewrite(child) + } + } + } + rewrite(value) + if !changed { + return payload + } + updated, err := json.Marshal(value) + if err != nil { + return payload + } + return updated +} + +func codexNamespaceChildren(requestPayloads [][]byte) map[string]string { + namespaces := make(map[string]string) + ambiguous := make(map[string]struct{}) + for _, payload := range requestPayloads { + if !gjson.ValidBytes(payload) { + continue + } + root := gjson.ParseBytes(payload) + collect := func(tools gjson.Result) {} + collect = func(tools gjson.Result) { + if !tools.IsArray() { + return + } + for _, tool := range tools.Array() { + if strings.TrimSpace(tool.Get("type").String()) != "namespace" { + continue + } + namespace := strings.TrimSpace(tool.Get("name").String()) + if namespace == "" { + continue + } + for _, child := range tool.Get("tools").Array() { + name := strings.TrimSpace(child.Get("name").String()) + if name == "" { + continue + } + if existing, ok := namespaces[name]; ok && existing != namespace { + delete(namespaces, name) + ambiguous[name] = struct{}{} + continue + } + if _, duplicate := ambiguous[name]; !duplicate { + namespaces[name] = namespace + } + } + } + } + collect(root.Get("tools")) + for _, item := range root.Get("input").Array() { + if item.Get("type").String() == "additional_tools" { + collect(item.Get("tools")) + } + } + } + return namespaces +} + func deleteHeaderCaseInsensitive(headers http.Header, key string) { for existingKey := range headers { if strings.EqualFold(existingKey, key) { diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index 719e1a36..9ce4a333 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -335,6 +335,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } reporter.MarkFirstResponseByte() payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) + payload = restoreCodexMultiAgentToolNamespace(payload, originalPayload, clientBody) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) payload = helps.RestoreCodexMultiAgentV2Response(payload, optimizeMultiAgentV2) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 310ac488..fbd30b2c 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -764,43 +764,65 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali return "", "" } - tools := gjson.GetBytes(requestRawJSON, "tools") - if !tools.Exists() || !tools.IsArray() { - return qualifiedName, "" - } - var bestNamespace string var bestChild string - tools.ForEach(func(_, tool gjson.Result) bool { - if strings.TrimSpace(tool.Get("type").String()) != "namespace" { - return true - } - namespaceName := strings.TrimSpace(tool.Get("name").String()) - if namespaceName == "" { - return true - } - children := tool.Get("tools") - if !children.Exists() || !children.IsArray() { - return true + var bareMatchNamespace string + var bareMatchChild string + bareMatchCount := 0 + collect := func(tools gjson.Result) { + if !tools.Exists() || !tools.IsArray() { + return } - children.ForEach(func(_, child gjson.Result) bool { - childName := responsesToolName(child) - if childName == "" { + tools.ForEach(func(_, tool gjson.Result) bool { + if strings.TrimSpace(tool.Get("type").String()) != "namespace" { + return true + } + namespaceName := strings.TrimSpace(tool.Get("name").String()) + if namespaceName == "" { return true } - if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName { - bestNamespace = namespaceName - bestChild = childName + children := tool.Get("tools") + if !children.Exists() || !children.IsArray() { + return true + } + children.ForEach(func(_, child gjson.Result) bool { + childName := responsesToolName(child) + if childName == "" { + return true + } + if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName { + bestNamespace = namespaceName + bestChild = childName + } + if childName == qualifiedName { + bareMatchCount++ + bareMatchNamespace = namespaceName + bareMatchChild = childName + } + return true + }) + return true + }) + } + + root := gjson.ParseBytes(requestRawJSON) + collect(root.Get("tools")) + if input := root.Get("input"); input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "additional_tools" { + collect(item.Get("tools")) } return true }) - return true - }) + } - if bestNamespace == "" || bestChild == "" { - return qualifiedName, "" + if bestNamespace != "" && bestChild != "" { + return bestChild, bestNamespace + } + if bareMatchCount == 1 { + return bareMatchChild, bareMatchNamespace } - return bestChild, bestNamespace + return qualifiedName, "" } func isUnsupportedOpenAIBuiltinToolType(toolType string) bool { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index 556bb251..ae42b6d5 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -383,3 +383,12 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesContentPartCacheControl( t.Fatalf("content.1 should not have cache_control. Output: %s", result) } } + +func TestSplitResponsesQualifiedFunctionCallRestoresBareAdditionalToolsNamespace(t *testing.T) { + request := []byte(`{"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"wait_agent"}]}]}]}`) + + name, namespace := splitResponsesQualifiedFunctionCallFromRequest(request, "wait_agent") + if name != "wait_agent" || namespace != "multi_agent_v1" { + t.Fatalf("got name=%q namespace=%q, want wait_agent/multi_agent_v1", name, namespace) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 671f98d0..929b0362 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -70,6 +70,52 @@ type claudeResponsesUsageTokens struct { var dataTag = []byte("data:") +var claudeMultiAgentArgumentTypes = map[string]map[string]string{ + "spawn_agent": { + "fork_context": "boolean", + "items": "array", + }, + "wait_agent": { + "targets": "array", + "timeout_ms": "integer", + }, + "send_input": { + "interrupt": "boolean", + "items": "array", + }, +} + +func normalizeClaudeMultiAgentArguments(name, arguments string) string { + bareName := strings.TrimPrefix(strings.TrimSpace(name), "multi_agent_v1__") + fields := claudeMultiAgentArgumentTypes[bareName] + if len(fields) == 0 || !gjson.Valid(arguments) { + return arguments + } + updated := []byte(arguments) + for field, expectedType := range fields { + value := gjson.GetBytes(updated, field) + if value.Type != gjson.String { + continue + } + inner := strings.TrimSpace(value.String()) + parsed := gjson.Parse(inner) + valid := false + switch expectedType { + case "array": + valid = gjson.Valid(inner) && parsed.IsArray() + case "integer": + valid = gjson.Valid(inner) && parsed.Type == gjson.Number && !strings.ContainsAny(inner, ".eE") + case "boolean": + valid = inner == "true" || inner == "false" + } + if !valid { + continue + } + updated, _ = sjson.SetRawBytes(updated, field, []byte(inner)) + } + return string(updated) +} + func (u *claudeResponsesUsageTokens) Merge(usage gjson.Result) { if !usage.Exists() { return @@ -428,6 +474,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin args = buf.String() } } + args = normalizeClaudeMultiAgentArguments(st.FuncNames[idx], args) fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) @@ -603,6 +650,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if callID == "" && st.CurrentFCID != "" { callID = st.CurrentFCID } + args = normalizeClaudeMultiAgentArguments(name, args) item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) item, _ = sjson.SetBytes(item, "arguments", args) @@ -886,6 +934,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if args == "" { args = "{}" } + args = normalizeClaudeMultiAgentArguments(st.name, args) item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", st.id)) item, _ = sjson.SetBytes(item, "arguments", args) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index 16e5a330..694c6c3b 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -31,6 +31,19 @@ func parseClaudeResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Res return event, gjson.Parse(data) } +func TestNormalizeClaudeMultiAgentArgumentsRestoresTypes(t *testing.T) { + got := normalizeClaudeMultiAgentArguments("multi_agent_v1__wait_agent", `{"targets":"[\"agent-1\"]","timeout_ms":"15000"}`) + if !gjson.Get(got, "targets").IsArray() { + t.Fatalf("targets should be an array after normalization: %s", got) + } + if value := gjson.Get(got, "targets.0").String(); value != "agent-1" { + t.Fatalf("targets.0 = %q, want agent-1; arguments=%s", value, got) + } + if value := gjson.Get(got, "timeout_ms"); value.Type != gjson.Number || value.Int() != 15000 { + t.Fatalf("timeout_ms should be integer 15000; arguments=%s", got) + } +} + func translateClaudeResponsesStreamThroughRegistry(chunks [][]byte) [][]byte { var param any var outputs [][]byte diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 9898744a..8e482301 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -24,6 +24,20 @@ func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Res return event, gjson.Parse(dataLine) } +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesRestoresBareNamespaceCall(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object"}}]}]}`) + raw := []byte(`{"id":"chatcmpl_bare_namespace","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_wait","type":"function","function":{"name":"wait_agent","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`) + + resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", request, nil, raw, nil) + data := gjson.ParseBytes(resp) + if got := data.Get("output.0.name").String(); got != "wait_agent" { + t.Fatalf("output name = %q, want wait_agent; response=%s", got, resp) + } + if got := data.Get("output.0.namespace").String(); got != "multi_agent_v1" { + t.Fatalf("output namespace = %q, want multi_agent_v1; response=%s", got, resp) + } +} + func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ResponseCompletedWaitsForDone(t *testing.T) { t.Parallel() diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index d4a9007b..9d2308fb 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -255,6 +255,9 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali var bestNamespace string var bestChild string + var bareMatchNamespace string + var bareMatchChild string + bareMatchCount := 0 collect := func(tools gjson.Result) { if !tools.Exists() || !tools.IsArray() { return @@ -280,6 +283,14 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali bestNamespace = namespaceName bestChild = childName } + // Some upstream models omit the namespace when calling a + // flattened namespace tool. Restore it only when the bare name + // identifies exactly one declared namespace child. + if childName == qualifiedName { + bareMatchCount++ + bareMatchNamespace = namespaceName + bareMatchChild = childName + } return true }) return true @@ -297,10 +308,13 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali }) } - if bestNamespace == "" || bestChild == "" { - return qualifiedName, "" + if bestNamespace != "" && bestChild != "" { + return bestChild, bestNamespace + } + if bareMatchCount == 1 { + return bareMatchChild, bareMatchNamespace } - return bestChild, bestNamespace + return qualifiedName, "" } func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {