Track multi-agent v2 spawn calls in analytics (#39722)

## What changed

- Emit started and completed collaboration tool events for multi-agent v2
  `spawn_agent` calls, including failed invocations.
- Record execution duration and successful child-agent configuration metadata
  without including the spawn prompt.
- Deduplicate collaboration and subagent activity items by call ID when
  calculating per-turn subagent tool counts.

## Testing

- Extend the app-server multi-agent v2 integration test to cover successful
  and failed spawn telemetry, duration, prompt omission, and turn counts.

GitOrigin-RevId: c1c2fb9cc2a1a9b0f63ef68509ad36d11a92d1e5
This commit is contained in:
jif
2026-08-20 14:33:55 +00:00
committed by copyberry
parent 1674b0a130
commit 9894a14c81
4 changed files with 182 additions and 11 deletions
+32
View File
@@ -41,6 +41,8 @@ use crate::reducer::valid_plugin_measurement_row;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
@@ -51,6 +53,9 @@ use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use std::collections::HashSet;
use std::path::PathBuf;
@@ -330,6 +335,33 @@ impl AnalyticsEventsClient {
));
}
pub fn track_collab_tool_call(
&self,
turn_id: String,
mut item: CollabAgentToolCallItem,
started_at_ms: i64,
completed_at_ms: i64,
) {
let thread_id = item.sender_thread_id.to_string();
let completed_item = TurnItem::CollabAgentToolCall(item.clone()).into();
item.status = CollabAgentToolCallStatus::InProgress;
self.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
item: TurnItem::CollabAgentToolCall(item).into(),
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
started_at_ms,
}));
self.track_notification(&ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: completed_item,
thread_id,
turn_id,
completed_at_ms,
},
));
}
pub fn track_code_mode_tool_call(&self, input: crate::facts::CodeModeToolCallFact) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::CodeModeToolCall(input),
+7 -2
View File
@@ -464,6 +464,7 @@ struct TurnToolCounts {
mcp_tool_call: usize,
dynamic_tool_call: usize,
subagent_tool_call: usize,
subagent_tool_call_ids: HashSet<String>,
web_search: usize,
image_generation: usize,
}
@@ -475,7 +476,11 @@ impl TurnToolCounts {
ThreadItem::FileChange { .. } => self.file_change += 1,
ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1,
ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1,
ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => {
ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::SubAgentActivity { id, .. } => {
if !self.subagent_tool_call_ids.insert(id.clone()) {
return;
}
self.subagent_tool_call += 1;
}
ThreadItem::WebSearch(_) => self.web_search += 1,
@@ -2672,7 +2677,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
ToolItemOutcome {
terminal_status,
failure_kind,
execution_duration_ms: None,
execution_duration_ms: observed_duration_ms(started_at_ms, completed_at_ms),
},
ToolItemContext {
started_at_ms,
@@ -3693,6 +3693,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
const CHILD_PROMPT: &str = "child: do work";
const PARENT_PROMPT: &str = "spawn a child and continue";
const SPAWN_CALL_ID: &str = "spawn-call-direct-input-rejection";
const FAILED_SPAWN_CALL_ID: &str = "spawn-call-invalid-fork-turns";
const ERROR_MESSAGE: &str =
"direct app-server input is not allowed for multi-agent v2 sub-agents";
@@ -3701,11 +3702,22 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
"message": CHILD_PROMPT,
"task_name": "worker",
}))?;
let failed_spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
"task_name": "invalid-worker",
"fork_turns": "0",
}))?;
let _parent_turn = responses::mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, PARENT_PROMPT),
responses::sse(vec![
responses::ev_response_created("resp-parent-1"),
responses::ev_function_call_with_namespace(
FAILED_SPAWN_CALL_ID,
MULTI_AGENT_V2_NAMESPACE,
"spawn_agent",
&failed_spawn_args,
),
responses::ev_function_call_with_namespace(
SPAWN_CALL_ID,
MULTI_AGENT_V2_NAMESPACE,
@@ -3719,8 +3731,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.enable_feature(Feature::MultiAgentV2)
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
.write(codex_home.path())?;
write_models_cache(codex_home.path())?;
mount_analytics_capture(&server, codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -3738,7 +3752,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id,
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: PARENT_PROMPT.to_string(),
text_elements: Vec::new(),
@@ -3752,6 +3766,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
assert!(!matches!(
&completed.item,
ThreadItem::CollabAgentToolCall { .. }
));
if let ThreadItem::SubAgentActivity {
id,
kind: SubAgentActivityKind::Started,
@@ -3825,7 +3843,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
let direct_steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: child_thread_id,
thread_id: child_thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "direct app-server steer".to_string(),
@@ -3844,6 +3862,63 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
assert_eq!(direct_steer_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(direct_steer_error.error.message, ERROR_MESSAGE);
let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
event["event_type"] == "codex_collab_agent_tool_call_event"
&& event["event_params"]["item_id"] == SPAWN_CALL_ID
})
.await?;
assert_eq!(
json!({
"tool_name": event["event_params"]["tool_name"],
"sender_thread_id": event["event_params"]["sender_thread_id"],
"receiver_thread_ids": event["event_params"]["receiver_thread_ids"],
"agent_state_count": event["event_params"]["agent_state_count"],
"terminal_status": event["event_params"]["terminal_status"],
}),
json!({
"tool_name": "spawn_agent",
"sender_thread_id": thread.id,
"receiver_thread_ids": [child_thread_id],
"agent_state_count": 1,
"terminal_status": "completed",
})
);
let duration_ms = event["event_params"]["duration_ms"]
.as_u64()
.context("successful spawn should report its execution duration")?;
assert_eq!(event["event_params"]["execution_duration_ms"], duration_ms);
assert!(!event.to_string().contains(CHILD_PROMPT));
let failed_event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
event["event_type"] == "codex_collab_agent_tool_call_event"
&& event["event_params"]["item_id"] == FAILED_SPAWN_CALL_ID
})
.await?;
assert_eq!(
json!({
"tool_name": failed_event["event_params"]["tool_name"],
"receiver_thread_count": failed_event["event_params"]["receiver_thread_count"],
"receiver_thread_ids": failed_event["event_params"]["receiver_thread_ids"],
"terminal_status": failed_event["event_params"]["terminal_status"],
"failure_kind": failed_event["event_params"]["failure_kind"],
}),
json!({
"tool_name": "spawn_agent",
"receiver_thread_count": 0,
"receiver_thread_ids": [],
"terminal_status": "failed",
"failure_kind": "tool_error",
})
);
assert!(!failed_event.to_string().contains(CHILD_PROMPT));
let turn_event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
event["event_type"] == "codex_turn_event" && event["event_params"]["thread_id"] == thread.id
})
.await?;
assert_eq!(turn_event["event_params"]["subagent_tool_call_count"], 2);
assert_eq!(turn_event["event_params"]["total_tool_call_count"], 2);
Ok(())
}
@@ -5,11 +5,15 @@ use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::session::multi_agents::resolve_usage_hints;
use crate::tools::handlers::multi_agents::collab_tool_call_status;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::tools::handlers::multi_agents_v2::message_tool::message_content;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ToolSpec;
@@ -34,13 +38,66 @@ impl ToolExecutor<ToolInvocation> for Handler {
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
Box::pin(async move {
let analytics = invocation.session.services.analytics_events_client.clone();
let sender_thread_id = invocation.session.thread_id;
let turn_id = invocation.step_context.turn.sub_id.clone();
let call_id = invocation.call_id.clone();
let started_at_ms = now_unix_timestamp_ms();
let result = handle_spawn_agent(invocation).await;
let completed_at_ms = now_unix_timestamp_ms();
let (status, receiver_thread_ids, agents_states) = match &result {
Ok((_, thread_id, agent_status, _)) => (
collab_tool_call_status(agent_status, Some(*thread_id)),
vec![*thread_id],
[(*thread_id, agent_status.clone())].into_iter().collect(),
),
Err(_) => (
CollabAgentToolCallStatus::Failed,
Vec::new(),
Default::default(),
),
};
let agent_snapshot = result
.as_ref()
.ok()
.and_then(|(_, _, _, snapshot)| snapshot.as_ref());
analytics.track_collab_tool_call(
turn_id,
CollabAgentToolCallItem {
id: call_id,
tool: CollabAgentTool::SpawnAgent,
status,
sender_thread_id,
receiver_thread_ids,
receiver_agents: Vec::new(),
prompt: None,
model: agent_snapshot.map(|snapshot| snapshot.model.clone()),
reasoning_effort: agent_snapshot
.and_then(|snapshot| snapshot.reasoning_effort.clone()),
agents_states,
},
started_at_ms,
completed_at_ms,
);
result.map(|(output, _, _, _)| boxed_tool_output(output))
})
}
}
async fn handle_spawn_agent(
invocation: ToolInvocation,
) -> Result<SpawnAgentResult, FunctionCallError> {
) -> Result<
(
SpawnAgentResult,
ThreadId,
AgentStatus,
Option<ThreadConfigSnapshot>,
),
FunctionCallError,
> {
let ToolInvocation {
session,
step_context,
@@ -163,6 +220,7 @@ async fn handle_spawn_agent(
.await
.map_err(collab_spawn_error)?;
let new_thread_id = spawned_agent.thread_id;
let agent_status = spawned_agent.status;
let agent_snapshot = session
.services
.agent_control
@@ -192,14 +250,15 @@ async fn handle_spawn_agent(
let task_name = String::from(new_agent_path);
let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata;
if hide_agent_metadata {
Ok(SpawnAgentResult::HiddenMetadata { task_name })
let output = if hide_agent_metadata {
SpawnAgentResult::HiddenMetadata { task_name }
} else {
Ok(SpawnAgentResult::WithNickname {
SpawnAgentResult::WithNickname {
task_name,
nickname,
})
}
}
};
Ok((output, new_thread_id, agent_status, agent_snapshot))
}
impl CoreToolRuntime for Handler {