use serde::{Deserialize, Serialize};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use crate::runtime::localization::localized_tool_display_name;
use crate::runtime::typed_id::TurnId;
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum CapabilityUsageKind {
Configured,
Resolved,
Exposed,
Invoked,
EffectRan,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CapabilityUsageRecord {
pub capability_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
pub usage_kind: CapabilityUsageKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CapabilityUsageData {
pub records: Vec<CapabilityUsageRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallSummary {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_narration: Option<String>,
}
impl From<&ToolCall> for ToolCallSummary {
fn from(tc: &ToolCall) -> Self {
Self {
id: tc.id.clone(),
name: tc.name.clone(),
display_name: None,
narration: None,
completed_narration: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolDefinitionSummary {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
pub description: String,
}
impl From<&crate::runtime::tool_types::ToolDefinition> for ToolDefinitionSummary {
fn from(tool: &crate::runtime::tool_types::ToolDefinition) -> Self {
let capability_attribution = tool.capability_attribution();
Self {
name: tool.name().to_string(),
display_name: tool.display_name().map(|s| s.to_string()),
category: tool.category().map(|s| s.to_string()),
capability_id: capability_attribution.map(|(id, _)| id.to_string()),
capability_name: capability_attribution.and_then(|(_, name)| name.map(str::to_string)),
description: tool.description().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ActStartedData {
pub tool_calls: Vec<ToolCallSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
}
impl ActStartedData {
pub fn new(tool_calls: &[ToolCall]) -> Self {
Self::new_with_locale(tool_calls, None)
}
pub fn new_with_locale(tool_calls: &[ToolCall], locale: Option<&str>) -> Self {
Self {
tool_calls: tool_calls.iter().map(ToolCallSummary::from).collect(),
headline: render_group_headline_with_locale(
tool_calls,
&[],
ToolNarrationPhase::Started,
locale,
),
}
}
pub fn with_definitions(
tool_calls: &[ToolCall],
tool_defs: &[crate::runtime::tool_types::ToolDefinition],
) -> Self {
Self::with_definitions_and_locale(tool_calls, tool_defs, None)
}
pub fn with_definitions_and_locale(
tool_calls: &[ToolCall],
tool_defs: &[crate::runtime::tool_types::ToolDefinition],
locale: Option<&str>,
) -> Self {
let def_map: std::collections::HashMap<&str, &crate::runtime::tool_types::ToolDefinition> =
tool_defs.iter().map(|d| (d.name(), d)).collect();
Self {
tool_calls: tool_calls
.iter()
.map(|tc| {
let tool_def = def_map.get(tc.name.as_str()).copied();
let display_name = localized_tool_display_name(
&tc.name,
tool_def.and_then(|d| d.display_name()),
locale,
);
ToolCallSummary {
id: tc.id.clone(),
name: tc.name.clone(),
display_name,
narration: Some(render_tool_narration_with_locale(
tool_def,
tc,
ToolNarrationPhase::Started,
locale,
)),
completed_narration: Some(render_tool_narration_with_locale(
tool_def,
tc,
ToolNarrationPhase::Completed,
locale,
)),
}
})
.collect(),
headline: render_group_headline_with_locale(
tool_calls,
tool_defs,
ToolNarrationPhase::Started,
locale,
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ActCompletedData {
pub completed: bool,
pub success_count: u32,
pub error_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolStartedData {
pub tool_call: ToolCall,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCompletedData {
pub tool_call_id: String,
pub tool_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_result_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub success: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Vec<ContentPart>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executed_arguments: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub executed_arguments_truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acted_as: Option<crate::runtime::mcp_server::McpServerActsAs>,
}
impl ToolCompletedData {
pub fn success(
tool_call_id: String,
tool_name: String,
result: Vec<ContentPart>,
duration_ms: Option<u64>,
) -> Self {
Self {
tool_call_id,
tool_name,
tool_call_fingerprint: None,
tool_result_fingerprint: None,
display_name: None,
success: true,
status: "success".to_string(),
result: Some(result),
error: None,
duration_ms,
capability_id: None,
capability_name: None,
narration: None,
executed_arguments: None,
executed_arguments_truncated: false,
acted_as: None,
}
}
pub fn failure(
tool_call_id: String,
tool_name: String,
status: String,
error: String,
duration_ms: Option<u64>,
) -> Self {
Self {
tool_call_id,
tool_name,
tool_call_fingerprint: None,
tool_result_fingerprint: None,
display_name: None,
success: false,
status,
result: None,
error: Some(error),
duration_ms,
capability_id: None,
capability_name: None,
narration: None,
executed_arguments: None,
executed_arguments_truncated: false,
acted_as: None,
}
}
pub fn with_display_name(mut self, display_name: Option<String>) -> Self {
self.display_name = display_name;
self
}
pub fn with_fingerprints(
mut self,
tool_call_fingerprint: String,
tool_result_fingerprint: String,
) -> Self {
self.tool_call_fingerprint = Some(tool_call_fingerprint);
self.tool_result_fingerprint = Some(tool_result_fingerprint);
self
}
pub fn with_narration(mut self, narration: Option<String>) -> Self {
self.narration = narration;
self
}
pub fn with_executed_arguments(
mut self,
authored: &serde_json::Value,
executed: &serde_json::Value,
) -> Self {
if authored != executed {
let mut redacted = redact_credential_fields(executed);
crate::secret_scrub::scrub_secrets_in_value(&mut redacted);
let (preview, truncated) =
crate::tool_approval_types::preview_tool_arguments(&redacted);
self.executed_arguments = Some(preview);
self.executed_arguments_truncated = truncated;
}
self
}
pub fn with_acted_as(
mut self,
acted_as: Option<crate::runtime::mcp_server::McpServerActsAs>,
) -> Self {
self.acted_as = acted_as;
self
}
pub fn with_capability_attribution(
mut self,
capability_id: Option<String>,
capability_name: Option<String>,
) -> Self {
self.capability_id = capability_id;
self.capability_name = capability_name;
self
}
}
const REDACTED_ARGUMENT: &str = crate::secret_scrub::REDACTED;
fn redact_credential_fields(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(object) => serde_json::Value::Object(
object
.iter()
.map(|(key, item)| {
let item = if crate::secret_scrub::is_credential_key(key) {
serde_json::Value::String(REDACTED_ARGUMENT.to_string())
} else {
redact_credential_fields(item)
};
(key.clone(), item)
})
.collect(),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(redact_credential_fields).collect())
}
other => other.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolProgressData {
pub tool_call_id: String,
pub tool_name: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolOutputDeltaData {
pub tool_call_id: String,
pub tool_name: String,
pub delta: String,
pub stream: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRepairAction {
Replay,
Synthesize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TranscriptRepairedData {
pub tool_call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
pub action: TranscriptRepairAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallRepairedData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub tool_call_id: String,
pub tool_name: String,
pub outcome: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct HostedToolCallData {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "turn_01933b5a00007000800000000000001"))]
pub turn_id: TurnId,
pub call_id: String,
pub tool_name: String,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallRequestedData {
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_summaries: Vec<ToolCallSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_headline: Option<String>,
}
impl ToolCallRequestedData {
pub fn with_definitions(
tool_calls: &[ToolCall],
tool_defs: &[crate::runtime::tool_types::ToolDefinition],
) -> Self {
Self::with_definitions_and_locale(tool_calls, tool_defs, None)
}
pub fn with_definitions_and_locale(
tool_calls: &[ToolCall],
tool_defs: &[crate::runtime::tool_types::ToolDefinition],
locale: Option<&str>,
) -> Self {
let def_map: std::collections::HashMap<&str, &crate::runtime::tool_types::ToolDefinition> =
tool_defs.iter().map(|d| (d.name(), d)).collect();
let tool_summaries = tool_calls
.iter()
.map(|tool_call| {
let tool_def = def_map.get(tool_call.name.as_str()).copied();
ToolCallSummary {
id: tool_call.id.clone(),
name: tool_call.name.clone(),
display_name: localized_tool_display_name(
&tool_call.name,
tool_def.and_then(|def| def.display_name()),
locale,
),
narration: Some(render_tool_narration_with_locale(
tool_def,
tool_call,
ToolNarrationPhase::Waiting,
locale,
)),
completed_narration: Some(render_tool_narration_with_locale(
tool_def,
tool_call,
ToolNarrationPhase::Completed,
locale,
)),
}
})
.collect();
Self {
tool_calls: tool_calls.to_vec(),
tool_summaries,
headline: render_group_headline_with_locale(
tool_calls,
tool_defs,
ToolNarrationPhase::Waiting,
locale,
),
completed_headline: render_group_headline_with_locale(
tool_calls,
tool_defs,
ToolNarrationPhase::Completed,
locale,
),
}
}
}
#[cfg(test)]
mod executed_arguments_tests {
use super::*;
use serde_json::json;
fn completed() -> ToolCompletedData {
ToolCompletedData::success("call_1".into(), "http".into(), Vec::new(), None)
}
#[test]
fn unchanged_arguments_are_not_recorded() {
let args = json!({ "url": "https://example.com" });
let data = completed().with_executed_arguments(&args, &args);
assert_eq!(data.executed_arguments, None);
let wire = serde_json::to_value(&data).unwrap();
assert!(wire.get("executed_arguments").is_none());
assert!(wire.get("executed_arguments_truncated").is_none());
}
#[test]
fn hook_injected_credentials_are_withheld() {
let authored = json!({ "url": "https://example.com", "max_tokens": 5 });
let executed = json!({
"url": "https://example.com",
"max_tokens": 5,
"headers": { "Authorization": "Bearer abc", "X-Api-Key": "k" },
"auth": [{ "access_token": "t", "client_secret": "s" }],
"password": "p",
});
let data = completed().with_executed_arguments(&authored, &executed);
assert_eq!(
data.executed_arguments,
Some(json!({
"url": "https://example.com",
"max_tokens": 5,
"headers": { "Authorization": "[REDACTED]", "X-Api-Key": "[REDACTED]" },
"auth": [{ "access_token": "[REDACTED]", "client_secret": "[REDACTED]" }],
"password": "[REDACTED]",
}))
);
assert!(!data.executed_arguments_truncated);
}
#[test]
fn hook_injected_secrets_under_innocuous_keys_are_scrubbed() {
let authored = json!({ "cmd": "curl https://api.example.com" });
let executed = json!({
"cmd": "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' https://api.example.com",
"env": ["OPENAI=sk-live0123456789abcdefXYZ", "AWS=AKIAABCDEFGHIJKLMNOP"],
"steps": [{ "remote": "https://bot:hunter2@git.example.com/o/r.git" }],
"note": "plain text stays",
});
let data = completed().with_executed_arguments(&authored, &executed);
assert_eq!(
data.executed_arguments,
Some(json!({
"cmd": "curl -H 'Authorization: [REDACTED]' https://api.example.com",
"env": ["OPENAI=[REDACTED]", "AWS=[REDACTED]"],
"steps": [{ "remote": "https://[REDACTED]@git.example.com/o/r.git" }],
"note": "plain text stays",
}))
);
}
#[test]
fn secret_free_rewrite_is_recorded_verbatim() {
let authored = json!({ "url": "https://example.com" });
let executed = json!({ "url": "https://example.com", "timeout_ms": 500, "tag": "a@b" });
let data = completed().with_executed_arguments(&authored, &executed);
assert_eq!(data.executed_arguments, Some(executed));
}
#[test]
fn rewrite_that_only_swaps_a_secret_is_still_recorded() {
let authored = json!({ "cmd": "use sk-aaaaaaaaaaaaaaaaaaaa" });
let executed = json!({ "cmd": "use sk-bbbbbbbbbbbbbbbbbbbb" });
let data = completed().with_executed_arguments(&authored, &executed);
assert_eq!(
data.executed_arguments,
Some(json!({ "cmd": "use [REDACTED]" }))
);
}
#[test]
fn secrets_are_scrubbed_before_truncation() {
use crate::tool_approval_types::TOOL_ARGUMENTS_PREVIEW_BYTES;
let key = "sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let prefix = r#"{"a":""#.len();
let pad = "x".repeat(TOOL_ARGUMENTS_PREVIEW_BYTES - prefix - 1 - 8);
let executed = json!({ "a": format!("{pad} {key}") });
let data = completed().with_executed_arguments(&json!({}), &executed);
assert!(data.executed_arguments_truncated);
let preview = data.executed_arguments.unwrap();
let preview = preview.as_str().unwrap();
assert!(
!preview.contains("sk-AAAA"),
"secret prefix leaked: {}",
&preview[preview.len() - 16..]
);
assert!(preview.ends_with(" [REDACTE"));
}
}