Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/model/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (follow-up, as noted in the PR body): core/plugins/cpex/cmf_parts.go:186 (the inferenceToCMFParts doc comment) and core/plugins/cpex/README.md:226 still name only function_call_output. The CPEX path now handles custom_tool_call_output automatically through inf.ToolResults, so those two are doc-only. core/plugins/script/sdk/cortex.py (_walk_body / _messages) is a real behaviour gap, though. A script plugin's redaction walk and tool-output detection will still skip Codex tool results. A follow-up issue or PR for that would be good, if #1770 doesn't already track it.

// 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
Expand Down
9 changes: 6 additions & 3 deletions core/parse/inference/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The paragraph this list belongs to (line 41, outside the diff) still says "Three shapes are read, the first two confirmed on live Codex traffic". There are now four, and this one is also confirmed on live Codex traffic (your 0.167 capture). Worth updating that line so the header matches the list.

//
// Every other item type (reasoning echoes, function_call round-trips) is
// skipped — this parser surfaces conversation text, the tool manifest, and
Expand Down Expand Up @@ -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" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The script SDK has the same match at core/plugins/script/sdk/cortex.py:139 (_walk_body, used for redaction) and :167 (_messages). Without the same change there, a redaction script does not see Codex tool output. See the review summary.

it.ToolResults = append(it.ToolResults, model.InferenceToolResult{
ToolUseID: raw.CallID,
Content: flattenResponsesContent(raw.Output),
Expand Down
31 changes: 31 additions & 0 deletions core/plugins/inferenceparser/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion praxis/filters/src/parse/inference/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ fn responses_input_item_unmarshal_json(data: &RawValue, body: &LossyBody) -> R<R
parameters: schema_object(t.parameters),
});
}
if typ == "function_call_output" {
if typ == "function_call_output" || typ == "custom_tool_call_output" {
it.tool_results.push(InferenceToolResult {
tool_use_id: call_id,
content: flatten_responses_content(output),
Expand Down Expand Up @@ -666,6 +666,13 @@ mod tests {
),
json!({"model":"m","messages":[{"role":"user","content":"q","contentBytes":57}],"toolResults":[{"toolUseId":"c","content":"a\nb"}],"isAction":true})
);
// Codex's custom tools send their results as custom_tool_call_output.
assert_eq!(
req(
r#"{"model":"m","input":[{"type":"custom_tool_call","call_id":"x","name":"exec","input":"ls"},{"type":"custom_tool_call_output","call_id":"x","output":[{"type":"input_text","text":"a\n"},{"type":"input_text","text":"b"}]}]}"#
),
json!({"model":"m","toolResults":[{"toolUseId":"x","content":"a\n\nb"}],"isAction":true})
);
assert_eq!(
req(
r#"{"model":"m","input":[{"type":"message","tools":[{"tools":[{"name":"t","parameters":null},{"name":""}]}]}]}"#
Expand Down
Loading