Track remaining multi-agent v2 tools in analytics (#40585)

## What changed

- Record collaboration tool analytics for `send_message`, `followup_task`,
  `interrupt_agent`, and `list_agents`, including receiver attribution, duration,
  failures, and interrupted calls.
- Include these calls in per-turn subagent tool counts while keeping the existing
  `subAgentActivity` output unchanged and omitting additional public collaborator
  tool items.
- Extend the collaboration tool schemas with the new tool variants and the
  `interrupted` terminal status.

## Testing

- Add an app-server integration test covering successful and failed calls,
  receiver attribution, duration, prompt omission, activity items, and turn
  counts.

GitOrigin-RevId: e73eaa05c508261e5fc97e7d08d7881a31e04e22
This commit is contained in:
jif
2026-08-25 10:17:03 +00:00
committed by copyberry
parent d52478c52e
commit 4fa6ad1730
38 changed files with 472 additions and 45 deletions
+5
View File
@@ -3212,6 +3212,7 @@ fn collab_tool_call_outcome(
match status {
CollabAgentToolCallStatus::InProgress => None,
CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)),
CollabAgentToolCallStatus::Interrupted => Some((ToolItemTerminalStatus::Interrupted, None)),
CollabAgentToolCallStatus::Failed => Some((
ToolItemTerminalStatus::Failed,
Some(ToolItemFailureKind::ToolError),
@@ -3236,6 +3237,10 @@ fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str {
CollabAgentTool::ResumeAgent => "resume_agent",
CollabAgentTool::Wait => "wait_agent",
CollabAgentTool::CloseAgent => "close_agent",
CollabAgentTool::SendMessage => "send_message",
CollabAgentTool::FollowupTask => "followup_task",
CollabAgentTool::InterruptAgent => "interrupt_agent",
CollabAgentTool::ListAgents => "list_agents",
}
}
@@ -783,7 +783,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -791,7 +795,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -8115,7 +8115,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -8123,7 +8127,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -4076,7 +4076,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -4084,7 +4088,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -65,7 +65,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -73,7 +77,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -65,7 +65,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -73,7 +77,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -204,7 +204,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -212,7 +216,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -285,7 +285,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -293,7 +297,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -285,7 +285,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -293,7 +297,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -285,7 +285,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -293,7 +297,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -207,7 +207,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -215,7 +219,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -204,7 +204,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -212,7 +216,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -204,7 +204,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -212,7 +216,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -204,7 +204,11 @@
"sendInput",
"resumeAgent",
"wait",
"closeAgent"
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents"
],
"type": "string"
},
@@ -212,7 +216,8 @@
"enum": [
"inProgress",
"completed",
"failed"
"failed",
"interrupted"
],
"type": "string"
},
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CollabAgentTool = "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent";
export type CollabAgentTool = "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent" | "sendMessage" | "followupTask" | "interruptAgent" | "listAgents";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed";
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | "interrupted";
@@ -1091,6 +1091,10 @@ pub enum CollabAgentTool {
ResumeAgent,
Wait,
CloseAgent,
SendMessage,
FollowupTask,
InterruptAgent,
ListAgents,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1183,6 +1187,7 @@ pub enum CollabAgentToolCallStatus {
InProgress,
Completed,
Failed,
Interrupted,
}
impl From<CoreCollabAgentTool> for CollabAgentTool {
@@ -1193,6 +1198,10 @@ impl From<CoreCollabAgentTool> for CollabAgentTool {
CoreCollabAgentTool::ResumeAgent => Self::ResumeAgent,
CoreCollabAgentTool::Wait => Self::Wait,
CoreCollabAgentTool::CloseAgent => Self::CloseAgent,
CoreCollabAgentTool::SendMessage => Self::SendMessage,
CoreCollabAgentTool::FollowupTask => Self::FollowupTask,
CoreCollabAgentTool::InterruptAgent => Self::InterruptAgent,
CoreCollabAgentTool::ListAgents => Self::ListAgents,
}
}
}
@@ -1203,6 +1212,7 @@ impl From<CoreCollabAgentToolCallStatus> for CollabAgentToolCallStatus {
CoreCollabAgentToolCallStatus::InProgress => Self::InProgress,
CoreCollabAgentToolCallStatus::Completed => Self::Completed,
CoreCollabAgentToolCallStatus::Failed => Self::Failed,
CoreCollabAgentToolCallStatus::Interrupted => Self::Interrupted,
}
}
}
+6
View File
@@ -1650,6 +1650,12 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, readOnlyHint, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, and `actionName` is the stable connector `Action.name`. `readOnlyHint` is `true` for read-only tools, `false` for write-capable tools, and `null` when the annotation is unavailable, including older rollout entries. The hint describes tool capability, not whether an invocation succeeded or performed a write; use `status`, `result`, and `error` to determine the execution outcome. `appName` and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `subAgentActivity` — `{id, kind, agentThreadId, agentPath}` describing Multi-Agent V2 lifecycle activity; `kind` is `started`, `interacted`, `interrupted`, or `completed`. A successful child completion is attributed to the parent turn that spawned it, so its `item/completed` notification may arrive after that turn's `turn/completed` notification and is included with that turn when history is read.
The `CollabAgentTool` schema also includes `sendMessage`, `followupTask`, `interruptAgent`, and
`listAgents` for private Multi-Agent V2 analytics. These calls do not emit public collaborator tool
items; their existing `subAgentActivity` notifications are unchanged, and `list_agents` emits no
activity item. Calls cancelled during handler execution are recorded privately with status
`interrupted`, distinct from tool failures.
- `webSearch` — `{id, query, action?, results?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. For standalone web search, `results` contains the out-of-band structured result DTOs returned by `/v1/alpha/search`; clients should ignore result types and fields they do not understand.
- `imageGeneration` — `{id, status, revisedPrompt, result, transparentBackground, savedPath?}` for a generated image. `transparentBackground` is `true` when the Images API reports a transparent background, `false` when it reports an opaque background, and `null` when the background is automatic, unavailable, or the item has not completed. The field is always present on v2 item payloads, including persisted and resumed items.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -1,15 +1,20 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -20,6 +25,7 @@ use codex_config::types::OtelHttpProtocol;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::loader::curated_plugin_cache_version;
use codex_core_plugins::store::PluginStore;
use codex_features::Feature;
use codex_plugin::PluginId;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
@@ -39,9 +45,198 @@ use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::path_regex;
const SERVICE_VERSION: &str = "0.0.0-test";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> {
const PARENT_PROMPT: &str = "exercise collaborator analytics";
const READ_TIMEOUT: Duration = Duration::from_secs(30);
let calls = [
(
"spawn_agent",
json!({"task_name": "worker", "message": "PRIVATE_CHILD", "fork_turns": "none"}),
),
(
"send_message",
json!({"target": "worker", "message": "PRIVATE_MESSAGE"}),
),
(
"followup_task",
json!({"target": "worker", "message": "PRIVATE_FOLLOWUP"}),
),
("interrupt_agent", json!({"target": "worker"})),
("list_agents", json!({})),
(
"send_message",
json!({"target": "missing", "message": "PRIVATE_MESSAGE"}),
),
(
"followup_task",
json!({"target": "/root", "message": "PRIVATE_FOLLOWUP"}),
),
("interrupt_agent", json!({"target": "/root"})),
("list_agents", json!({"unexpected": true})),
("send_message", json!({})),
];
let server = responses::start_mock_server().await;
for (index, (tool, args)) in calls.iter().enumerate() {
let response_id = format!("parent-response-{index}");
responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| {
String::from_utf8_lossy(&request.body).contains(PARENT_PROMPT)
},
responses::sse(vec![
responses::ev_response_created(&response_id),
responses::ev_function_call_with_namespace(
&format!("call-{index}"),
"collaboration",
tool,
&args.to_string(),
),
responses::ev_completed(&response_id),
]),
)
.await;
}
// Child turns and the final parent request only need an ordinary completion.
Mock::given(method("POST"))
.and(path_regex(".*/responses$"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(responses::sse(vec![responses::ev_completed(
"response-done",
)])),
)
.with_priority(10)
.mount(&server)
.await;
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())?;
mount_analytics_capture(&server, codex_home.path()).await?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.build_initialized()
.await?;
let thread = app_server
.start_thread(ThreadStartParams::default())
.await?
.thread;
let completed = app_server
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: PARENT_PROMPT.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let read: ThreadReadResponse = app_server
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
},
})
.await?;
let items = &read
.thread
.turns
.iter()
.find(|turn| turn.id == completed.turn.id)
.expect("completed parent turn should be readable")
.items;
let child_thread_id = items
.iter()
.find_map(|item| match item {
ThreadItem::SubAgentActivity {
id,
agent_thread_id,
..
} if id == "call-0" => Some(agent_thread_id.clone()),
_ => None,
})
.expect("spawn should retain its public activity item");
assert!(
!items
.iter()
.any(|item| matches!(item, ThreadItem::CollabAgentToolCall { .. }))
);
let activity_ids = items
.iter()
.filter_map(|item| match item {
ThreadItem::SubAgentActivity { id, .. } if id.starts_with("call-") => Some(id.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(activity_ids, vec!["call-0", "call-1", "call-2", "call-3"]);
for (index, (tool, _)) in calls.iter().enumerate() {
let event = wait_for_matching_analytics_event(&server, READ_TIMEOUT, |event| {
event["event_type"] == "codex_collab_agent_tool_call_event"
&& event["event_params"]["item_id"] == format!("call-{index}")
})
.await?;
let params = &event["event_params"];
let receivers = match index {
0..=3 => vec![child_thread_id.clone()],
6 | 7 => vec![thread.id.clone()],
_ => Vec::new(),
};
assert_eq!(
json!({
"tool": params["tool_name"],
"sender": params["sender_thread_id"],
"receivers": params["receiver_thread_ids"],
"receiverCount": params["receiver_thread_count"],
"turn": params["turn_id"],
"status": params["terminal_status"],
"origin": params["originating_response_id"],
"failure": params["failure_kind"],
"hasDuration": params["execution_duration_ms"].is_u64(),
}),
json!({
"tool": tool,
"sender": thread.id,
"receivers": receivers,
"receiverCount": receivers.len(),
"turn": completed.turn.id,
"status": if index < 5 { "completed" } else { "failed" },
"origin": format!("parent-response-{index}"),
"failure": if index < 5 { None } else { Some("tool_error") },
"hasDuration": true,
})
);
assert_eq!(params["duration_ms"], params["execution_duration_ms"]);
assert!(!event.to_string().contains("PRIVATE_"));
}
let turn_event = wait_for_matching_analytics_event(&server, READ_TIMEOUT, |event| {
event["event_type"] == "codex_turn_event"
&& event["event_params"]["turn_id"] == completed.turn.id
})
.await?;
assert_eq!(
json!({
"total": turn_event["event_params"]["total_tool_call_count"],
"subagent": turn_event["event_params"]["subagent_tool_call_count"],
"dynamic": turn_event["event_params"]["dynamic_tool_call_count"],
}),
json!({"total": calls.len(), "subagent": calls.len(), "dynamic": 0})
);
Ok(())
}
fn set_metrics_exporter(config: &mut codex_core::config::Config) {
config.otel.metrics_exporter = OtelExporterKind::OtlpHttp {
endpoint: "http://localhost:4318".to_string(),
@@ -36,6 +36,7 @@ pub(crate) use send_message::Handler as SendMessageHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;
mod analytics;
mod followup_task;
mod interrupt_agent;
mod list_agents;
@@ -0,0 +1,62 @@
use codex_analytics::AnalyticsEventsClient;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus;
use crate::tools::context::ToolInvocation;
use crate::turn_timing::now_unix_timestamp_ms;
/// Records private collaborator analytics without emitting another public tool item.
pub(super) struct ToolCallAnalytics {
client: AnalyticsEventsClient,
turn_id: String,
item: CollabAgentToolCallItem,
started_at_ms: i64,
}
impl ToolCallAnalytics {
pub(super) fn new(invocation: &ToolInvocation, tool: CollabAgentTool) -> Self {
Self {
client: invocation.session.services.analytics_events_client.clone(),
turn_id: invocation.turn.sub_id.clone(),
item: CollabAgentToolCallItem {
// Activity and analytics must use the same ID for turn-count deduplication.
id: invocation.call_id.clone(),
tool,
status: CollabAgentToolCallStatus::Interrupted,
sender_thread_id: invocation.session.thread_id,
receiver_thread_ids: Vec::new(),
receiver_agents: Vec::new(),
prompt: None,
model: None,
reasoning_effort: None,
agents_states: Default::default(),
},
started_at_ms: now_unix_timestamp_ms(),
}
}
pub(super) fn set_receiver(&mut self, thread_id: ThreadId) {
self.item.receiver_thread_ids = vec![thread_id];
}
pub(super) fn finish<T, E>(mut self, result: &Result<T, E>) {
self.item.status = if result.is_ok() {
CollabAgentToolCallStatus::Completed
} else {
CollabAgentToolCallStatus::Failed
};
}
}
impl Drop for ToolCallAnalytics {
fn drop(&mut self) {
self.client.track_collab_tool_call(
self.turn_id.clone(),
self.item.clone(),
self.started_at_ms,
now_unix_timestamp_ms(),
);
}
}
@@ -1,3 +1,4 @@
use super::analytics::ToolCallAnalytics;
use super::message_tool::FollowupTaskArgs;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::handle_message_string_tool;
@@ -17,7 +18,12 @@ impl ToolExecutor<ToolInvocation> for Handler {
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
Box::pin(async move {
let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::FollowupTask);
let result = self.handle_call(invocation, &mut analytics).await;
analytics.finish(&result);
result
})
}
}
@@ -25,6 +31,7 @@ impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
analytics: &mut ToolCallAnalytics,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: FollowupTaskArgs = parse_arguments(&arguments)?;
@@ -33,6 +40,7 @@ impl Handler {
MessageDeliveryMode::TriggerTurn,
args.target,
args.message,
analytics,
)
.await
.map(boxed_tool_output)
@@ -1,3 +1,4 @@
use super::analytics::ToolCallAnalytics;
use super::*;
use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2;
use codex_protocol::error::CodexErrorDetails;
@@ -16,15 +17,18 @@ impl ToolExecutor<ToolInvocation> for Handler {
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
handle_interrupt_agent(invocation)
.await
.map(boxed_tool_output)
let mut analytics =
ToolCallAnalytics::new(&invocation, CollabAgentTool::InterruptAgent);
let result = handle_interrupt_agent(invocation, &mut analytics).await;
analytics.finish(&result);
result.map(boxed_tool_output)
})
}
}
async fn handle_interrupt_agent(
invocation: ToolInvocation,
analytics: &mut ToolCallAnalytics,
) -> Result<InterruptAgentResult, FunctionCallError> {
let ToolInvocation {
session,
@@ -36,6 +40,7 @@ async fn handle_interrupt_agent(
let arguments = function_arguments(payload)?;
let args: InterruptAgentArgs = parse_arguments(&arguments)?;
let agent_id = resolve_agent_target(&session, &turn, &args.target).await?;
analytics.set_receiver(agent_id);
let receiver_agent = session
.services
.agent_control
@@ -1,3 +1,4 @@
use super::analytics::ToolCallAnalytics;
use super::*;
use crate::agent::control::ListedAgent;
use crate::tools::handlers::multi_agents_spec::create_list_agents_tool;
@@ -15,7 +16,12 @@ impl ToolExecutor<ToolInvocation> for Handler {
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
Box::pin(async move {
let analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::ListAgents);
let result = self.handle_call(invocation).await;
analytics.finish(&result);
result
})
}
}
@@ -3,6 +3,7 @@
//! `send_message` and `followup_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
use super::analytics::ToolCallAnalytics;
use super::*;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
@@ -49,11 +50,12 @@ pub(super) fn message_content(message: String) -> Result<String, FunctionCallErr
}
/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`.
pub(crate) async fn handle_message_string_tool(
pub(super) async fn handle_message_string_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
message: String,
analytics: &mut ToolCallAnalytics,
) -> Result<FunctionToolOutput, FunctionCallError> {
let message = message_content(message)?;
let ToolInvocation {
@@ -64,6 +66,7 @@ pub(crate) async fn handle_message_string_tool(
..
} = invocation;
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
analytics.set_receiver(receiver_thread_id);
let receiver_agent = session
.services
.agent_control
@@ -1,3 +1,4 @@
use super::analytics::ToolCallAnalytics;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::SendMessageArgs;
use super::message_tool::handle_message_string_tool;
@@ -17,7 +18,12 @@ impl ToolExecutor<ToolInvocation> for Handler {
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
Box::pin(async move {
let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::SendMessage);
let result = self.handle_call(invocation, &mut analytics).await;
analytics.finish(&result);
result
})
}
}
@@ -25,6 +31,7 @@ impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
analytics: &mut ToolCallAnalytics,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: SendMessageArgs = parse_arguments(&arguments)?;
@@ -33,6 +40,7 @@ impl Handler {
MessageDeliveryMode::QueueOnly,
args.target,
args.message,
analytics,
)
.await
.map(boxed_tool_output)
@@ -244,6 +244,10 @@ impl EventProcessorWithJsonOutput {
id: make_id(),
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
tool: match tool {
CollabAgentTool::SendMessage
| CollabAgentTool::FollowupTask
| CollabAgentTool::InterruptAgent
| CollabAgentTool::ListAgents => return None,
CollabAgentTool::SpawnAgent => CollabTool::SpawnAgent,
CollabAgentTool::SendInput => CollabTool::SendInput,
CollabAgentTool::ResumeAgent => CollabTool::Wait,
@@ -291,6 +295,7 @@ impl EventProcessorWithJsonOutput {
CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress,
CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed,
CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed,
CollabAgentToolCallStatus::Interrupted => return None,
},
}),
}),
+5
View File
@@ -288,6 +288,10 @@ pub enum CollabAgentTool {
ResumeAgent,
Wait,
CloseAgent,
SendMessage,
FollowupTask,
InterruptAgent,
ListAgents,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
@@ -296,6 +300,7 @@ pub enum CollabAgentToolCallStatus {
InProgress,
Completed,
Failed,
Interrupted,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
+9
View File
@@ -248,6 +248,11 @@ impl CollabAgentToolCallItem {
pub(crate) fn as_legacy_begin_event(&self, started_at_ms: i64) -> Option<EventMsg> {
let receiver_thread_id = self.receiver_thread_ids.first().copied();
match self.tool {
// V2 records these tool items privately for analytics, not legacy UI events.
CollabAgentTool::SendMessage
| CollabAgentTool::FollowupTask
| CollabAgentTool::InterruptAgent
| CollabAgentTool::ListAgents => None,
CollabAgentTool::SpawnAgent => Some(EventMsg::CollabAgentSpawnBegin(
CollabAgentSpawnBeginEvent {
call_id: self.id.clone(),
@@ -303,6 +308,10 @@ impl CollabAgentToolCallItem {
}
let receiver_thread_id = self.receiver_thread_ids.first().copied();
match self.tool {
CollabAgentTool::SendMessage
| CollabAgentTool::FollowupTask
| CollabAgentTool::InterruptAgent
| CollabAgentTool::ListAgents => None,
CollabAgentTool::SpawnAgent => {
let (new_agent_nickname, new_agent_role) = receiver_thread_id
.map(|thread_id| self.receiver_agent_identity(thread_id))
@@ -158,6 +158,10 @@ fn activity_summary(item: &ThreadItem) -> Option<String> {
}
ThreadItem::CollabAgentToolCall { tool, .. } => {
let action = match tool {
CollabAgentTool::SendMessage
| CollabAgentTool::FollowupTask
| CollabAgentTool::InterruptAgent
| CollabAgentTool::ListAgents => return None,
CollabAgentTool::SpawnAgent => "Spawned an agent",
CollabAgentTool::SendInput => "Sent input to an agent",
CollabAgentTool::ResumeAgent => "Resumed an agent",
+5
View File
@@ -223,6 +223,11 @@ pub(crate) fn tool_call_history_cell(
let prompt = prompt.as_deref().unwrap_or_default();
match tool {
// V2 uses SubAgentActivity for display; these variants are analytics-only.
CollabAgentTool::SendMessage
| CollabAgentTool::FollowupTask
| CollabAgentTool::InterruptAgent
| CollabAgentTool::ListAgents => None,
CollabAgentTool::SpawnAgent => {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
return None;