diff --git a/internal/extension/codextool/customtool.go b/internal/extension/codextool/customtool.go index a83bf167..e3ea20ff 100644 --- a/internal/extension/codextool/customtool.go +++ b/internal/extension/codextool/customtool.go @@ -116,11 +116,27 @@ func OutputItemFromBlock( return "custom_tool_call", spec.OpenAIName, "", InputFromRaw(toolInput), false, nil case ToolFunction: return "function_call", spec.OpenAIName, spec.Namespace, string(toolInput), false, nil + case ToolNestedNamespace: + subName, paramsStr := decodeNestedNamespaceArguments(toolInput) + return "function_call", subName, blockName, paramsStr, false, nil default: return "function_call", blockName, "", string(toolInput), false, nil } } +type nestedCall struct { + Action string `json:"action"` + Params json.RawMessage `json:"params"` +} + +func decodeNestedNamespaceArguments(input json.RawMessage) (string, string) { + var call nestedCall + if err := json.Unmarshal(input, &call); err != nil { + return "", string(input) + } + return call.Action, string(call.Params) +} + // Proxy schema builders (return map[string]any for format.CoreTool.InputSchema) func ApplyPatchToolActions() []string { diff --git a/internal/extension/codextool/customtool_test.go b/internal/extension/codextool/customtool_test.go index c1a55d2d..febe32c2 100644 --- a/internal/extension/codextool/customtool_test.go +++ b/internal/extension/codextool/customtool_test.go @@ -85,3 +85,41 @@ func TestRebuildGrammarUsesRawInputForGenericCustomTools(t *testing.T) { t.Fatalf("RebuildGrammar() = %q, want raw input", got) } } + +func TestOutputItemFromBlockForToolNestedNamespace(t *testing.T) { + toolMap := ToolMap{ + "mcp__filesystem": ToolSpec{ + Kind: ToolNestedNamespace, + OpenAIName: "mcp__filesystem", + }, + } + + input := json.RawMessage(`{ + "action": "read_file", + "params": { + "path": "/Users/francesco.mosca/Work/explorations/README.md" + } + }`) + + itemType, itemName, itemNamespace, toolInputStr, isLocalShell, _ := OutputItemFromBlock( + "mcp__filesystem", + input, + toolMap, + ) + + if itemType != "function_call" { + t.Errorf("expected itemType function_call, got %s", itemType) + } + if itemName != "read_file" { + t.Errorf("expected itemName read_file, got %s", itemName) + } + if itemNamespace != "mcp__filesystem" { + t.Errorf("expected itemNamespace mcp__filesystem, got %s", itemNamespace) + } + if !strings.Contains(toolInputStr, `"/Users/francesco.mosca/Work/explorations/README.md"`) { + t.Errorf("expected toolInputStr to contain path, got %s", toolInputStr) + } + if isLocalShell { + t.Errorf("expected isLocalShell false, got true") + } +} diff --git a/internal/extension/codextool/tool_context.go b/internal/extension/codextool/tool_context.go index cd8561a3..0cd31a51 100644 --- a/internal/extension/codextool/tool_context.go +++ b/internal/extension/codextool/tool_context.go @@ -11,12 +11,13 @@ package codextool type ToolKind string const ( - ToolApplyPatch ToolKind = "apply_patch" - ToolExec ToolKind = "exec" - ToolRaw ToolKind = "raw" - ToolFunction ToolKind = "function" - ToolLocalShell ToolKind = "local_shell" - ToolUnknown ToolKind = "unknown" + ToolApplyPatch ToolKind = "apply_patch" + ToolExec ToolKind = "exec" + ToolRaw ToolKind = "raw" + ToolFunction ToolKind = "function" + ToolLocalShell ToolKind = "local_shell" + ToolNestedNamespace ToolKind = "nested_namespace" + ToolUnknown ToolKind = "unknown" ) // ToolSpec describes an expanded tool entry for reverse mapping. diff --git a/internal/protocol/openai/adapter.go b/internal/protocol/openai/adapter.go index 32038518..02556b34 100644 --- a/internal/protocol/openai/adapter.go +++ b/internal/protocol/openai/adapter.go @@ -373,6 +373,12 @@ func (a *OpenAIAdapter) streamLoop(ctx context.Context, coreReq *format.CoreRequ reasonIndexes := make(map[int]bool) toolCallFinalized := make(map[int]bool) + type nestedBufferState struct { + toolName string + toolUseID string + } + nestedBuffers := make(map[int]*nestedBufferState) + for event := range events { // Let hooks skip events. if a.hooks.OnStreamEvent(ctx, event) { @@ -468,6 +474,18 @@ func (a *OpenAIAdapter) streamLoop(ctx context.Context, coreReq *format.CoreRequ } itemIDs[index] = fmt.Sprintf("fc_item_%d", index) toolBlockNames[index] = event.ContentBlock.ToolName + + // Check if this is a nested namespace tool + toolMap := codextool.DecodeToolMapFromExtensions(coreReq.Extensions) + if spec, ok := toolMap.Lookup(event.ContentBlock.ToolName); ok && spec.Kind == codextool.ToolNestedNamespace { + nestedBuffers[index] = &nestedBufferState{ + toolName: event.ContentBlock.ToolName, + toolUseID: toolUseID, + } + // Buffering: do NOT emit response.output_item.added yet + break + } + item := buildToolOutputItemStreaming(event.ContentBlock, coreReq.Extensions, toolUseID) outputIndexes[index] = len(response.Output) response.Output = append(response.Output, item) @@ -619,6 +637,12 @@ func (a *OpenAIAdapter) streamLoop(ctx context.Context, coreReq *format.CoreRequ case format.CoreToolCallArgsDelta: index := event.Index toolCallArgs[index] += event.Delta + + // If this is a buffered nested namespace call, we accumulate but do NOT stream delta to Codex + if _, isBuffered := nestedBuffers[index]; isBuffered { + break + } + send(StreamEvent{ Event: "response.function_call_arguments.delta", Data: FunctionCallArgumentsDeltaEvent{ @@ -642,6 +666,73 @@ func (a *OpenAIAdapter) streamLoop(ctx context.Context, coreReq *format.CoreRequ if finalArgs == "" { finalArgs = toolCallArgs[index] } + + // If this is a buffered nested namespace call, we resolve it now! + if nBuf, isBuffered := nestedBuffers[index]; isBuffered { + type nestedCall struct { + Action string `json:"action"` + Params json.RawMessage `json:"params"` + } + var call nestedCall + _ = json.Unmarshal([]byte(finalArgs), &call) + + // 1. Emit output_item.added with the clean action name as Name, and toolName as Namespace! + item := OutputItem{ + Type: "function_call", + ID: nBuf.toolUseID, + CallID: nBuf.toolUseID, + Name: call.Action, + Namespace: nBuf.toolName, + Status: "in_progress", + } + outIdx := len(response.Output) + outputIndexes[index] = outIdx + response.Output = append(response.Output, item) + + send(StreamEvent{ + Event: "response.output_item.added", + Data: OutputItemEvent{ + Type: "response.output_item.added", + SequenceNumber: next(), + OutputIndex: outIdx, + Item: item, + }, + }) + + // 2. Override finalArgs with the clean, unescaped params! + finalArgs = string(call.Params) + response.Output[outIdx].Arguments = finalArgs + response.Output[outIdx].Status = "completed" + + toolCallFinalized[index] = true + + // 3. Emit function_call_arguments.done + send(StreamEvent{ + Event: "response.function_call_arguments.done", + Data: FunctionCallArgumentsDoneEvent{ + Type: "response.function_call_arguments.done", + SequenceNumber: next(), + ItemID: itemIDs[index], + OutputIndex: outIdx, + Arguments: finalArgs, + }, + }) + + // 4. Emit output_item.done + send(StreamEvent{ + Event: "response.output_item.done", + Data: OutputItemEvent{ + Type: "response.output_item.done", + SequenceNumber: next(), + OutputIndex: outIdx, + Item: response.Output[outIdx], + }, + }) + + delete(nestedBuffers, index) + break + } + if idx, ok := outputIndexes[index]; ok && idx < len(response.Output) { response.Output[idx].Arguments = finalArgs response.Output[idx].Status = "completed" @@ -933,6 +1024,7 @@ type inputItem struct { Summary json.RawMessage `json:"summary"` CallID string `json:"call_id"` Name string `json:"name"` + Namespace string `json:"namespace"` Arguments string `json:"arguments"` Output json.RawMessage `json:"output"` Input string `json:"input"` @@ -1085,10 +1177,32 @@ func convertInput(raw json.RawMessage, model string) ([]format.CoreMessage, []fo if !json.Valid([]byte(item.Arguments)) { toolInput = json.RawMessage(`{}`) } + toolName := item.Name + if item.Namespace != "" { + toolName = item.Namespace + var paramsObj any + if err := json.Unmarshal(toolInput, ¶msObj); err == nil { + nested := map[string]any{ + "action": item.Name, + "params": paramsObj, + } + nestedBytes, err := json.Marshal(nested) + if err == nil { + toolInput = json.RawMessage(nestedBytes) + } + } else { + nested := map[string]any{ + "action": item.Name, + "params": string(toolInput), + } + nestedBytes, _ := json.Marshal(nested) + toolInput = json.RawMessage(nestedBytes) + } + } pendingFCBlocks = append(pendingFCBlocks, format.CoreContentBlock{ Type: "tool_use", ToolUseID: firstNonEmpty(item.CallID, item.ID), - ToolName: item.Name, + ToolName: toolName, ToolInput: toolInput, }) @@ -1477,13 +1591,17 @@ func buildToolOutputItem(block format.CoreContentBlock, extensions map[string]an Action: localShellActionFromRaw(actionJSON), } } + arguments := toolInputString(block.ToolInput) + if itemT == "function_call" && itemInput != "" && itemInput != string(block.ToolInput) { + arguments = itemInput + } return OutputItem{ Type: itemT, ID: block.ToolUseID, CallID: block.ToolUseID, Name: itemN, Namespace: itemNS, - Arguments: toolInputString(block.ToolInput), + Arguments: arguments, Input: itemInput, Status: "completed", } @@ -1494,7 +1612,6 @@ func buildToolOutputItem(block format.CoreContentBlock, extensions map[string]an func buildToolOutputItemStreaming(block *format.CoreContentBlock, extensions map[string]any, toolUseID string) OutputItem { toolMap := codextool.DecodeToolMapFromExtensions(extensions) itemT, itemN, itemNS, itemInput, isLS, actionJSON := codextool.OutputItemFromBlock(block.ToolName, block.ToolInput, toolMap) - _ = itemInput if isLS { return OutputItem{ Type: "local_shell_call", @@ -1504,13 +1621,17 @@ func buildToolOutputItemStreaming(block *format.CoreContentBlock, extensions map Action: localShellActionFromRaw(actionJSON), } } + arguments := toolInputString(block.ToolInput) + if itemT == "function_call" && itemInput != "" && itemInput != string(block.ToolInput) { + arguments = itemInput + } return OutputItem{ Type: itemT, ID: toolUseID, CallID: toolUseID, Name: itemN, Namespace: itemNS, - Arguments: toolInputString(block.ToolInput), + Arguments: arguments, Status: "in_progress", } } @@ -1572,8 +1693,7 @@ func convertToolWithNamespace(tool Tool, namespace string, disablePatchProxy fun }} case "namespace": - ns := namespacedToolName(namespace, tool.Name) - return flattenToolsWithNamespace(tool.Tools, ns, disablePatchProxy) + return []format.CoreTool{convertNamespaceToNestedTool(tool, namespace)} case "custom": grammar := codextool.CustomToolGrammar(tool.Format) @@ -1639,6 +1759,9 @@ func flattenToolsWithNamespace(openaiTools []Tool, namespace string, disablePatc seen := make(map[string]bool, len(result)) deduped := make([]format.CoreTool, 0, len(result)) for _, t := range result { + if t.Name == "" { + continue + } if seen[t.Name] { continue } @@ -1682,3 +1805,58 @@ func outputToContentBlocks(raw json.RawMessage) []format.CoreContentBlock { } return nil } + +func convertNamespaceToNestedTool(tool Tool, namespace string) format.CoreTool { + actionEnum := []string{} + anyOfSchemas := []map[string]any{} + + for _, sub := range tool.Tools { + actionEnum = append(actionEnum, sub.Name) + + // Each anyOf option represents one schema + subSchema := map[string]any{ + "type": "object", + "title": sub.Name + "_params", + "description": sub.Description, + } + if sub.Parameters != nil { + // Copy all fields from sub.Parameters + for k, v := range sub.Parameters { + if k != "title" && k != "description" { + subSchema[k] = v + } + } + } else { + subSchema["properties"] = map[string]any{} + } + anyOfSchemas = append(anyOfSchemas, subSchema) + } + + nestedSchema := map[string]any{ + "type": "object", + "required": []string{"action", "params"}, + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "description": "The specific tool operation to perform within this namespace.", + "enum": actionEnum, + }, + "params": map[string]any{ + "type": "object", + "description": "Specific parameters corresponding to the selected action.", + "anyOf": anyOfSchemas, + }, + }, + } + + name := namespacedToolName(namespace, tool.Name) + ct := format.CoreTool{ + Name: name, + Description: tool.Description, + InputSchema: nestedSchema, + } + + codextool.AnnotateCoreTool(&ct, codextool.ToolNestedNamespace, name, "") + + return ct +} diff --git a/internal/protocol/openai/adapter_test.go b/internal/protocol/openai/adapter_test.go index 0bcca6d5..32893037 100644 --- a/internal/protocol/openai/adapter_test.go +++ b/internal/protocol/openai/adapter_test.go @@ -365,3 +365,61 @@ func TestFromCoreStream_NoDuplicateDoneForToolUse(t *testing.T) { t.Fatalf("output_item.done (tool) count=%d, want 1", itemDone) } } + +// TestToCoreRequest_NamespacedToolCallReconstruction verifies that when Codex +// sends a history item with a namespace (e.g. name="edit_file", namespace="mcp__filesystem"), +// it is reconstructed as a nested call to the namespace tool mcp__filesystem with nested action and params. +func TestToCoreRequest_NamespacedToolCallReconstruction(t *testing.T) { + adapter := openai.NewOpenAIAdapter(format.CorePluginHooks{}) + + arguments := `{"edits": [{"newText": "foo", "oldText": "bar"}], "path": "/Users/test/file"}` + + req := &openai.ResponsesRequest{ + Model: "gpt-4o", + Input: json.RawMessage(`[ + {"type":"function_call","call_id":"call_namespaced","name":"edit_file","namespace":"mcp__filesystem","arguments":"` + strings.ReplaceAll(arguments, "\"", "\\\"") + `"} + ]`), + } + + result, err := adapter.ToCoreRequest(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + var toolUse *format.CoreContentBlock + for i := range result.Messages { + for j := range result.Messages[i].Content { + b := &result.Messages[i].Content[j] + if b.Type == "tool_use" && b.ToolUseID == "call_namespaced" { + toolUse = b + } + } + } + if toolUse == nil { + t.Fatal("expected tool_use block with call_id=call_namespaced") + } + + // ToolName must be the namespace + if toolUse.ToolName != "mcp__filesystem" { + t.Errorf("expected ToolName to be 'mcp__filesystem', got: %s", toolUse.ToolName) + } + + // ToolInput must contain action and params + parsed := map[string]any{} + if err := json.Unmarshal(toolUse.ToolInput, &parsed); err != nil { + t.Fatalf("tool_use ToolInput must be valid JSON, got: %s", string(toolUse.ToolInput)) + } + + if parsed["action"] != "edit_file" { + t.Errorf("expected action to be 'edit_file', got: %v", parsed["action"]) + } + + params, ok := parsed["params"].(map[string]any) + if !ok { + t.Fatalf("expected params to be an object, got: %v", parsed["params"]) + } + + if params["path"] != "/Users/test/file" { + t.Errorf("expected path to be '/Users/test/file', got: %v", params["path"]) + } +}