feat: record all tool call types (#26855)

## Summary

The Responses interceptor previously recorded only `function_call` and `custom_tool_call` output items, so interceptions that did real work via built-in tools (`web_search_call`, `computer_call`, `shell_call`, `mcp_call`, etc.) recorded no tool usage at all.

`recordNonInjectedToolUsage` now whitelists every tool-call output type and records it, with the tool name falling back to the item type when none is set.

`ToolUsageRecord` also gains an `ItemID` field so the two distinct Responses identifiers are captured without conflation (addresses review feedback on coder/aibridge#273):

- `ItemID`: the output item's unique `id` (always present).
- `ToolCallID`: the `call_id` correlation id (empty for hosted tools the provider runs server-side).

## Tests

- Extends `TestRecordToolUsage` with cases for the new hosted/agentic tool types.
- Adds blocking and streaming `web_search` fixtures (scrubbed of credentials and identifying metadata) plus `TestResponsesOutputMatchesUpstream` cases asserting a hosted tool records with an empty `ToolCallID` and a populated `ItemID`.

Linear: AIGOV-96

---
*This PR was produced by opencode (agent) using the `anthropic/claude-opus-4-8` model, under human direction and review.*
This commit is contained in:
Danny Kopping
2026-07-06 09:10:21 +02:00
committed by GitHub
parent 14d17abae6
commit 08a6359cac
7 changed files with 366 additions and 10 deletions
+30 -6
View File
@@ -257,25 +257,49 @@ func (i *responsesInterceptionBase) recordNonInjectedToolUsage(ctx context.Conte
for _, item := range response.Output {
var args recorder.ToolArgs
// recording other function types to be considered: https://github.com/coder/aibridge/issues/121
// Whitelist the output item types that represent tool calls. Every
// other output type (message, reasoning, *_output, etc.) is skipped.
// Only function_call and custom_tool_call carry arguments we parse;
// the remaining built-in tool calls are recorded for visibility but
// have no uniform argument representation.
switch item.Type {
case string(constant.ValueOf[constant.FunctionCall]()):
args = i.parseFunctionCallJSONArgs(ctx, item.Arguments)
case string(constant.ValueOf[constant.CustomToolCall]()):
args = item.Input
case string(constant.ValueOf[constant.WebSearchCall]()),
// computer_call has no SDK constant; only computer_call_output does.
"computer_call",
string(constant.ValueOf[constant.LocalShellCall]()),
string(constant.ValueOf[constant.ShellCall]()),
string(constant.ValueOf[constant.ApplyPatchCall]()),
string(constant.ValueOf[constant.CodeInterpreterCall]()),
string(constant.ValueOf[constant.McpCall]()),
string(constant.ValueOf[constant.FileSearchCall]()),
string(constant.ValueOf[constant.ImageGenerationCall]()):
// Built-in tool calls carry no uniform argument payload.
default:
continue
}
// Built-in tools usually have no name, so fall back to the type.
toolName := item.Name
if toolName == "" {
toolName = item.Type
}
if err := i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{
InterceptionID: i.ID().String(),
MsgID: response.ID,
ToolCallID: item.CallID,
Tool: item.Name,
Args: args,
Injected: false,
// ItemID is always present; ToolCallID (call_id) is empty for
// hosted tools that the provider executes internally.
ItemID: item.ID,
ToolCallID: item.CallID,
Tool: toolName,
Args: args,
Injected: false,
}); err != nil {
i.logger.Warn(ctx, "failed to record tool usage", slog.Error(err), slog.F("tool", item.Name))
i.logger.Warn(ctx, "failed to record tool usage", slog.Error(err), slog.F("tool", toolName))
}
}
}
@@ -200,6 +200,89 @@ func TestRecordToolUsage(t *testing.T) {
},
},
},
{
// Function/agentic tools expose both id and call_id; both are captured.
name: "function_call_captures_both_ids",
response: &oairesponses.Response{
ID: "resp_both",
Output: []oairesponses.ResponseOutputItemUnion{
{
Type: "function_call",
ID: "fc_item_1",
CallID: "call_both",
Name: "get_weather",
Arguments: `{"location": "NYC"}`,
},
},
},
expected: []*recorder.ToolUsageRecord{
{
InterceptionID: id.String(),
MsgID: "resp_both",
ItemID: "fc_item_1",
ToolCallID: "call_both",
Tool: "get_weather",
Args: map[string]any{"location": "NYC"},
Injected: false,
},
},
},
{
// Hosted tools only have id (no call_id) and usually no name, so
// the type is recorded as the tool name and ToolCallID is empty.
name: "hosted_tool_uses_item_id_no_call_id",
response: &oairesponses.Response{
ID: "resp_ws",
Output: []oairesponses.ResponseOutputItemUnion{
{
Type: "web_search_call",
ID: "ws_abc",
},
},
},
expected: []*recorder.ToolUsageRecord{
{
InterceptionID: id.String(),
MsgID: "resp_ws",
ItemID: "ws_abc",
ToolCallID: "",
Tool: "web_search_call",
Injected: false,
},
},
},
{
// Exercises every newly recorded tool type, the name-falls-back-to
// -type behavior, an explicit name override (mcp_call), and that
// non-tool output items (reasoning) are still skipped.
name: "all_additional_tool_types",
response: &oairesponses.Response{
ID: "resp_all",
Output: []oairesponses.ResponseOutputItemUnion{
{Type: "reasoning", ID: "rs_skip"},
{Type: "web_search_call", ID: "ws_1"},
{Type: "computer_call", ID: "cu_1", CallID: "call_cu"},
{Type: "local_shell_call", ID: "ls_1", CallID: "call_ls"},
{Type: "shell_call", ID: "sh_1", CallID: "call_sh"},
{Type: "apply_patch_call", ID: "ap_1", CallID: "call_ap"},
{Type: "code_interpreter_call", ID: "ci_1"},
{Type: "mcp_call", ID: "mcp_1", Name: "fetch"},
{Type: "file_search_call", ID: "fs_1"},
{Type: "image_generation_call", ID: "ig_1"},
},
},
expected: []*recorder.ToolUsageRecord{
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "ws_1", Tool: "web_search_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "cu_1", ToolCallID: "call_cu", Tool: "computer_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "ls_1", ToolCallID: "call_ls", Tool: "local_shell_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "sh_1", ToolCallID: "call_sh", Tool: "shell_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "ap_1", ToolCallID: "call_ap", Tool: "apply_patch_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "ci_1", Tool: "code_interpreter_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "mcp_1", Tool: "fetch"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "fs_1", Tool: "file_search_call"},
{InterceptionID: id.String(), MsgID: "resp_all", ItemID: "ig_1", Tool: "image_generation_call"},
},
},
}
for _, tc := range tests {