use serde_json::Value;
use crate::emit::emit_kind;
use crate::event::{EventKind, PAYLOAD_TRUNCATE_BYTES, truncate_utf8};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostedToolCall {
pub tool_name: String,
pub provider_call_id: Option<String>,
pub call_id: String,
pub status: Option<String>,
pub args_json: String,
pub result_json: String,
pub raw: Value,
pub output_index: usize,
}
const INPUT_KEYS: &[&str] = &[
"action",
"queries",
"query",
"code",
"input",
"arguments",
"container_id",
"parameters",
];
const OUTPUT_KEYS: &[&str] = &["output", "results", "result", "output_text"];
#[must_use]
pub fn extract_hosted_tools(payload: &Value) -> Vec<HostedToolCall> {
let Some(items) = payload.get("output").and_then(Value::as_array) else {
return Vec::new();
};
let mut calls = Vec::new();
for (output_index, item) in items.iter().enumerate() {
let Some(call) = parse_hosted_item(item, output_index) else {
continue;
};
calls.push(call);
}
calls
}
fn parse_hosted_item(item: &Value, output_index: usize) -> Option<HostedToolCall> {
let raw_type = item.get("type").and_then(Value::as_str)?;
if raw_type == "function_call" || raw_type == "function_call_output" {
return None;
}
let tool_name = match raw_type.strip_suffix("_call") {
Some(stripped) if !stripped.is_empty() => stripped.to_owned(),
_ => return None,
};
let provider_call_id = item.get("id").and_then(Value::as_str).map(str::to_owned);
let call_id = provider_call_id
.clone()
.unwrap_or_else(|| format!("{tool_name}-{output_index}"));
let status = item
.get("status")
.and_then(Value::as_str)
.map(str::to_owned);
let args_json = compact_json_subset(item, INPUT_KEYS);
let result_json = compact_json_subset(item, OUTPUT_KEYS);
Some(HostedToolCall {
tool_name,
provider_call_id,
call_id,
status,
args_json,
result_json,
raw: item.clone(),
output_index,
})
}
fn compact_json_subset(item: &Value, keys: &[&str]) -> String {
let Some(obj) = item.as_object() else {
return String::new();
};
let mut picked = serde_json::Map::new();
for key in keys {
if let Some(value) = obj.get(*key) {
picked.insert((*key).to_owned(), value.clone());
}
}
if picked.is_empty() {
return String::new();
}
serde_json::to_string(&Value::Object(picked)).unwrap_or_default()
}
pub fn emit_hosted_tools(
conversation_id: impl AsRef<str>,
response_id: Option<&str>,
payload: &Value,
) -> usize {
let conversation_id = conversation_id.as_ref();
let resolved_response_id = response_id
.map(str::to_owned)
.or_else(|| payload.get("id").and_then(Value::as_str).map(str::to_owned));
let calls = extract_hosted_tools(payload);
let count = calls.len();
for call in calls {
let HostedToolCall {
tool_name,
provider_call_id,
call_id,
status,
args_json,
result_json,
..
} = call;
let (args_payload, args_truncated) = truncate_utf8(&args_json, PAYLOAD_TRUNCATE_BYTES);
let (result_payload, result_truncated) =
truncate_utf8(&result_json, PAYLOAD_TRUNCATE_BYTES);
emit_kind(
conversation_id,
EventKind::ToolHostedInvoked {
tool_name: tool_name.clone(),
provider_call_id: provider_call_id.clone(),
call_id: call_id.clone(),
response_id: resolved_response_id.clone(),
args_json: args_payload,
truncated: args_truncated,
},
);
emit_kind(
conversation_id,
EventKind::ToolHostedCompleted {
tool_name,
provider_call_id,
call_id,
response_id: resolved_response_id.clone(),
status,
result: result_payload,
truncated: result_truncated,
duration_ms: None,
},
);
}
count
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extracts_web_search_call() {
let payload = json!({
"id": "resp_1",
"output": [
{
"type": "web_search_call",
"id": "ws_42",
"status": "completed",
"action": { "type": "search", "queries": ["rig observability"] },
"results": [{ "url": "https://example.com" }],
}
]
});
let calls = extract_hosted_tools(&payload);
assert_eq!(calls.len(), 1);
let call = &calls[0];
assert_eq!(call.tool_name, "web_search");
assert_eq!(call.provider_call_id.as_deref(), Some("ws_42"));
assert_eq!(call.call_id, "ws_42");
assert_eq!(call.status.as_deref(), Some("completed"));
assert!(call.args_json.contains("queries"));
assert!(call.result_json.contains("example.com"));
assert_eq!(call.output_index, 0);
}
#[test]
fn extracts_multiple_kinds_in_order() {
let payload = json!({
"output": [
{ "type": "message", "id": "m1", "content": [] },
{ "type": "file_search_call", "id": "fs_1", "status": "in_progress",
"queries": ["spec"] },
{ "type": "function_call", "id": "fn_1", "name": "calc",
"arguments": "{}" },
{ "type": "code_interpreter_call", "id": "ci_1", "status": "completed",
"code": "print(1)", "output": "1\n" },
]
});
let calls = extract_hosted_tools(&payload);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].tool_name, "file_search");
assert_eq!(calls[0].output_index, 1);
assert_eq!(calls[1].tool_name, "code_interpreter");
assert_eq!(calls[1].output_index, 3);
assert!(calls[1].args_json.contains("print"));
assert!(calls[1].result_json.contains("1"));
}
#[test]
fn synthesizes_call_id_when_provider_omits_id() {
let payload = json!({
"output": [
{ "type": "computer_use_call", "status": "completed" }
]
});
let calls = extract_hosted_tools(&payload);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].provider_call_id, None);
assert_eq!(calls[0].call_id, "computer_use-0");
}
#[test]
fn forward_compatible_for_unknown_hosted_kind() {
let payload = json!({
"output": [
{ "type": "future_thing_call", "id": "ft_1", "status": "completed" }
]
});
let calls = extract_hosted_tools(&payload);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].tool_name, "future_thing");
}
#[test]
fn ignores_function_call_and_non_call_items() {
let payload = json!({
"output": [
{ "type": "function_call", "id": "fn_1", "name": "x", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "fn_1", "output": "ok" },
{ "type": "message", "id": "m1", "content": [] },
{ "type": "reasoning", "id": "r1", "summary": [] },
]
});
assert!(extract_hosted_tools(&payload).is_empty());
}
#[test]
fn returns_empty_when_output_missing_or_wrong_shape() {
assert!(extract_hosted_tools(&json!({})).is_empty());
assert!(extract_hosted_tools(&json!({ "output": "not an array" })).is_empty());
assert!(extract_hosted_tools(&json!({ "output": [] })).is_empty());
}
#[test]
#[cfg(feature = "subscriber")]
fn emit_hosted_tools_emits_invoked_and_completed_per_call() {
use crate::subscriber::CapturingLayer;
use tracing_subscriber::prelude::*;
let layer = CapturingLayer::new();
let probe = layer.clone();
let payload = json!({
"id": "resp_xyz",
"output": [
{ "type": "web_search_call", "id": "ws_1", "status": "completed",
"action": { "type": "search", "queries": ["a"] },
"results": [{ "url": "u" }] },
{ "type": "file_search_call", "id": "fs_1", "status": "completed",
"queries": ["b"], "results": [{ "id": "doc1" }] },
]
});
let count =
tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), || {
emit_hosted_tools("conv-1", None, &payload)
});
assert_eq!(count, 2);
let events = probe.snapshot();
assert_eq!(events.len(), 4);
let kinds: Vec<&str> = events.iter().map(|e| e.kind.discriminant()).collect();
assert_eq!(
kinds,
vec![
"tool.hosted_invoked",
"tool.hosted_completed",
"tool.hosted_invoked",
"tool.hosted_completed",
]
);
for event in &events {
assert_eq!(event.kind.scalar_fields().response_id, "resp_xyz");
}
}
}