diff --git a/core/model/extensions.go b/core/model/extensions.go index bda41c566..337beb5bb 100644 --- a/core/model/extensions.go +++ b/core/model/extensions.go @@ -142,9 +142,10 @@ type InferenceExtension struct { ServedModel string `json:"servedModel,omitempty"` // ToolResults are the tool_result blocks of an Anthropic request and the - // function_call_output items of a Responses API request — what each earlier tool - // call returned, as the client sent it back. Message Content keeps only text blocks, - // so without this a tool result left nothing behind but its size in ContentBytes. + // function_call_output and custom_tool_call_output items of a Responses API request — + // what each earlier tool call returned, as the client sent it back. Message Content + // keeps only text blocks, so without this a tool result left nothing behind but its + // size in ContentBytes. // // KEPT OUT OF MESSAGES ON PURPOSE. Plugins read Messages — IBAC as the user's intent, // OPA/SPARC/CPEX as policy input, lineage as an exported span — and tool output reaches diff --git a/core/parse/inference/responses.go b/core/parse/inference/responses.go index b8802c9e1..516dfcba3 100644 --- a/core/parse/inference/responses.go +++ b/core/parse/inference/responses.go @@ -59,8 +59,11 @@ type responsesRequest struct { // Messages, for the same reason the Anthropic one does: tool output // must not reach the plugins that read Messages. The output value is // read with flattenResponsesContent, which accepts the string form this -// API documents (and Codex sends) and a text-part array should one -// arrive. +// API documents and a text-part array should one arrive. +// - "custom_tool_call_output": the same thing for a custom (freeform) +// tool, with the same call_id and output fields. Codex declares its +// tools this way (one "exec" tool taking a script), so every tool result +// it sends arrives as this type, with the output as input_text parts. // // Every other item type (reasoning echoes, function_call round-trips) is // skipped — this parser surfaces conversation text, the tool manifest, and @@ -106,7 +109,7 @@ func (it *responsesInputItem) UnmarshalJSON(data []byte) error { }) } } - if raw.Type == "function_call_output" { + if raw.Type == "function_call_output" || raw.Type == "custom_tool_call_output" { it.ToolResults = append(it.ToolResults, model.InferenceToolResult{ ToolUseID: raw.CallID, Content: flattenResponsesContent(raw.Output), diff --git a/core/plugins/inferenceparser/responses_test.go b/core/plugins/inferenceparser/responses_test.go index ef05ae7e8..f1edefb59 100644 --- a/core/plugins/inferenceparser/responses_test.go +++ b/core/plugins/inferenceparser/responses_test.go @@ -470,6 +470,37 @@ func TestInferenceParser_ResponsesRequest_ToolResults(t *testing.T) { } } +// Codex declares its tools as custom (freeform) tools, so its tool results arrive as +// custom_tool_call_output items, with the output as a list of input_text parts. They +// are read the same way as function_call_output. The body is trimmed from a captured +// Codex 0.167 request. +func TestInferenceParser_ResponsesRequest_CustomToolResults(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{ + Path: "/backend-api/codex/responses", + Body: []byte(`{"model":"gpt-5","input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"print a.txt"}]}, + {"type":"custom_tool_call","call_id":"call_x","name":"exec","input":"const r = await tools.exec_command({cmd:\"cat a.txt\"});\ntext(r.output);\n"}, + {"type":"custom_tool_call_output","call_id":"call_x","output":[ + {"type":"input_text","text":"Script completed\nOutput:\n"}, + {"type":"input_text","text":"hello\n"}]} + ]}`), + } + p.OnRequest(context.Background(), pctx) + + ext := pctx.Extensions.Inference + if ext == nil { + t.Fatal("Extensions.Inference is nil") + } + want := pipeline.InferenceToolResult{ToolUseID: "call_x", Content: "Script completed\nOutput:\n\nhello\n"} + if len(ext.ToolResults) != 1 || ext.ToolResults[0] != want { + t.Fatalf("ToolResults = %+v, want [%+v]", ext.ToolResults, want) + } + if len(ext.Messages) != 1 || ext.Messages[0].Content != "print a.txt" { + t.Errorf("Messages = %+v, want the user message only", ext.Messages) + } +} + // runResponsesFrames feeds frames through OnResponseFrame as a live listener would and // returns the extension once the stream has ended. func runResponsesFrames(t *testing.T, frames ...string) (*pipeline.Context, *pipeline.InferenceExtension) { diff --git a/praxis/filters/src/parse/inference/responses.rs b/praxis/filters/src/parse/inference/responses.rs index a28842f39..2aff94935 100644 --- a/praxis/filters/src/parse/inference/responses.rs +++ b/praxis/filters/src/parse/inference/responses.rs @@ -98,7 +98,7 @@ fn responses_input_item_unmarshal_json(data: &RawValue, body: &LossyBody) -> R