mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user