Skip to content

Commit 56fb57a

Browse files
committed
feat(core): emit SubagentProgress for child tool/turn milestones
The `SubagentProgress` event variant exists but until now was never sent — the subagent task tracker introduced in the previous commit observed Start and End but had no mid-task signal to update from. Adds a `synthesize_subagent_progress()` helper called by the mpsc → broadcast forwarder inside `TaskExecutor::execute_with_task_id`. It translates two child-loop events into compact progress milestones on the parent broadcast: - `ToolEnd` → `status = "tool_completed"`, metadata `{ tool, exit_code, output_bytes, error_kind? }` - `TurnEnd` → `status = "turn_completed"`, metadata `{ turn, total_tokens, prompt_tokens, completion_tokens }` Noisy events (TextDelta / ToolStart / ToolOutputDelta / nested Subagent*) are intentionally not translated — consumers needing token-level detail should subscribe to the raw event stream directly. The original child events are still forwarded unchanged; progress is additive, so downstream consumers see both the synthesized milestone and the underlying event.
1 parent 57cd9a3 commit 56fb57a

2 files changed

Lines changed: 242 additions & 2 deletions

File tree

core/src/agent_api/tests.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,6 +2388,57 @@ async fn subagent_events_populate_session_tracker() {
23882388
assert_eq!(session.subagent_tasks().await.len(), 1);
23892389
}
23902390

2391+
#[tokio::test]
2392+
async fn subagent_progress_events_accumulate_in_tracker() {
2393+
use super::runtime_events::RuntimeEventSink;
2394+
use crate::agent::AgentEvent;
2395+
2396+
let agent = Agent::from_config(test_config()).await.unwrap();
2397+
let session = agent
2398+
.session("/tmp/test-ws-subagent-progress", None)
2399+
.unwrap();
2400+
2401+
let run = session
2402+
.run_store
2403+
.create_run(session.session_id(), "parent prompt")
2404+
.await;
2405+
let sink = RuntimeEventSink::from_session(&session, &run.id);
2406+
2407+
let task_id = "task-progress".to_string();
2408+
let child_session_id = format!("task-run-{}", task_id);
2409+
2410+
sink.observe(&AgentEvent::SubagentStart {
2411+
task_id: task_id.clone(),
2412+
session_id: child_session_id.clone(),
2413+
parent_session_id: session.session_id().to_string(),
2414+
agent: "explore".to_string(),
2415+
description: "demo".to_string(),
2416+
})
2417+
.await;
2418+
2419+
sink.observe(&AgentEvent::SubagentProgress {
2420+
task_id: task_id.clone(),
2421+
session_id: child_session_id.clone(),
2422+
status: "tool_completed".to_string(),
2423+
metadata: serde_json::json!({ "tool": "bash", "exit_code": 0 }),
2424+
})
2425+
.await;
2426+
2427+
sink.observe(&AgentEvent::SubagentProgress {
2428+
task_id: task_id.clone(),
2429+
session_id: child_session_id.clone(),
2430+
status: "turn_completed".to_string(),
2431+
metadata: serde_json::json!({ "turn": 1, "total_tokens": 50 }),
2432+
})
2433+
.await;
2434+
2435+
let snap = session.subagent_task(&task_id).await.unwrap();
2436+
assert_eq!(snap.progress.len(), 2);
2437+
assert_eq!(snap.progress[0].status, "tool_completed");
2438+
assert_eq!(snap.progress[1].status, "turn_completed");
2439+
assert_eq!(snap.progress[1].metadata["total_tokens"], 50);
2440+
}
2441+
23912442
#[tokio::test]
23922443
async fn subagent_tasks_scope_to_parent_session() {
23932444
use super::runtime_events::RuntimeEventSink;

core/src/tools/task.rs

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,60 @@ fn compact_task_output(output: &str) -> (String, bool) {
9292
)
9393
}
9494

95+
/// Translate selected child-loop events into a `SubagentProgress` milestone
96+
/// for the parent broadcast. Returns `None` for events that aren't worth
97+
/// surfacing as progress (text deltas, tool starts, subagent events from
98+
/// nested delegation, etc.).
99+
///
100+
/// Currently emits progress for:
101+
/// - `ToolEnd` → `status = "tool_completed"`,
102+
/// `metadata = { tool, exit_code, output_bytes, error_kind? }`
103+
/// - `TurnEnd` → `status = "turn_completed"`,
104+
/// `metadata = { turn, total_tokens, prompt_tokens, completion_tokens }`
105+
fn synthesize_subagent_progress(
106+
event: &AgentEvent,
107+
task_id: &str,
108+
session_id: &str,
109+
) -> Option<AgentEvent> {
110+
match event {
111+
AgentEvent::ToolEnd {
112+
name,
113+
output,
114+
exit_code,
115+
error_kind,
116+
..
117+
} => {
118+
let mut metadata = serde_json::json!({
119+
"tool": name,
120+
"exit_code": exit_code,
121+
"output_bytes": output.len(),
122+
});
123+
if let Some(kind) = error_kind {
124+
metadata["error_kind"] =
125+
serde_json::to_value(kind).unwrap_or(serde_json::Value::Null);
126+
}
127+
Some(AgentEvent::SubagentProgress {
128+
task_id: task_id.to_string(),
129+
session_id: session_id.to_string(),
130+
status: "tool_completed".to_string(),
131+
metadata,
132+
})
133+
}
134+
AgentEvent::TurnEnd { turn, usage } => Some(AgentEvent::SubagentProgress {
135+
task_id: task_id.to_string(),
136+
session_id: session_id.to_string(),
137+
status: "turn_completed".to_string(),
138+
metadata: serde_json::json!({
139+
"turn": turn,
140+
"total_tokens": usage.total_tokens,
141+
"prompt_tokens": usage.prompt_tokens,
142+
"completion_tokens": usage.completion_tokens,
143+
}),
144+
}),
145+
_ => None,
146+
}
147+
}
148+
95149
fn task_artifact_id(result: &TaskResult) -> String {
96150
format!("task-output:{}", result.task_id)
97151
}
@@ -300,14 +354,25 @@ impl TaskExecutor {
300354
child_config,
301355
);
302356

303-
// Create an mpsc channel for the child agent and forward events to broadcast
357+
// Create an mpsc channel for the child agent and forward events to broadcast.
358+
// Selected child events (ToolEnd, TurnEnd) are also surfaced to the parent
359+
// broadcast as synthetic `SubagentProgress` events so dashboards can observe
360+
// mid-task milestones without subscribing to the raw event stream.
304361
let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
305362
let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
306363
let broadcast_tx_clone = broadcast_tx.clone();
364+
let progress_task_id = task_id.clone();
365+
let progress_session_id = session_id.clone();
307366

308-
// Spawn a task to forward events from mpsc to broadcast
309367
tokio::spawn(async move {
310368
while let Some(event) = mpsc_rx.recv().await {
369+
if let Some(progress) = synthesize_subagent_progress(
370+
&event,
371+
&progress_task_id,
372+
&progress_session_id,
373+
) {
374+
let _ = broadcast_tx_clone.send(progress);
375+
}
311376
let _ = broadcast_tx_clone.send(event);
312377
}
313378
});
@@ -1962,10 +2027,12 @@ mod tests {
19622027

19632028
let mut starts = Vec::new();
19642029
let mut ends = Vec::new();
2030+
let mut progress_statuses: Vec<String> = Vec::new();
19652031
while let Ok(event) = rx.try_recv() {
19662032
match event {
19672033
AgentEvent::SubagentStart { description, .. } => starts.push(description),
19682034
AgentEvent::SubagentEnd { agent, success, .. } => ends.push((agent, success)),
2035+
AgentEvent::SubagentProgress { status, .. } => progress_statuses.push(status),
19692036
_ => {}
19702037
}
19712038
}
@@ -1976,6 +2043,17 @@ mod tests {
19762043
assert!(ends
19772044
.iter()
19782045
.all(|(agent, success)| agent == "worker" && *success));
2046+
// Each child loop emits at least one TurnEnd, so we expect at least
2047+
// two synthesized turn_completed progress events across the run.
2048+
assert!(
2049+
progress_statuses
2050+
.iter()
2051+
.filter(|s| s == &"turn_completed")
2052+
.count()
2053+
>= 2,
2054+
"expected at least two turn_completed progress events, got {:?}",
2055+
progress_statuses
2056+
);
19792057
}
19802058

19812059
#[tokio::test]
@@ -2082,4 +2160,115 @@ mod tests {
20822160
);
20832161
}
20842162
}
2163+
2164+
#[test]
2165+
fn synthesize_progress_emits_tool_completed_for_tool_end() {
2166+
let event = AgentEvent::ToolEnd {
2167+
id: "call-1".to_string(),
2168+
name: "bash".to_string(),
2169+
output: "hello".to_string(),
2170+
exit_code: 0,
2171+
metadata: None,
2172+
error_kind: None,
2173+
};
2174+
let progress =
2175+
synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2176+
match progress {
2177+
AgentEvent::SubagentProgress {
2178+
task_id,
2179+
session_id,
2180+
status,
2181+
metadata,
2182+
} => {
2183+
assert_eq!(task_id, "task-1");
2184+
assert_eq!(session_id, "task-run-task-1");
2185+
assert_eq!(status, "tool_completed");
2186+
assert_eq!(metadata["tool"], "bash");
2187+
assert_eq!(metadata["exit_code"], 0);
2188+
assert_eq!(metadata["output_bytes"], 5);
2189+
assert!(metadata.get("error_kind").is_none());
2190+
}
2191+
other => panic!("expected SubagentProgress, got {:?}", other),
2192+
}
2193+
}
2194+
2195+
#[test]
2196+
fn synthesize_progress_includes_error_kind_when_present() {
2197+
let event = AgentEvent::ToolEnd {
2198+
id: "call-2".to_string(),
2199+
name: "edit".to_string(),
2200+
output: "boom".to_string(),
2201+
exit_code: 1,
2202+
metadata: None,
2203+
error_kind: Some(crate::tools::ToolErrorKind::NotFound {
2204+
path: "missing.txt".to_string(),
2205+
}),
2206+
};
2207+
let progress =
2208+
synthesize_subagent_progress(&event, "task-x", "task-run-task-x").expect("some");
2209+
if let AgentEvent::SubagentProgress { metadata, .. } = progress {
2210+
assert!(
2211+
metadata.get("error_kind").is_some(),
2212+
"error_kind should propagate into metadata"
2213+
);
2214+
} else {
2215+
panic!("expected SubagentProgress");
2216+
}
2217+
}
2218+
2219+
#[test]
2220+
fn synthesize_progress_emits_turn_completed_for_turn_end() {
2221+
let event = AgentEvent::TurnEnd {
2222+
turn: 3,
2223+
usage: crate::llm::TokenUsage {
2224+
prompt_tokens: 100,
2225+
completion_tokens: 25,
2226+
total_tokens: 125,
2227+
cache_read_tokens: None,
2228+
cache_write_tokens: None,
2229+
},
2230+
};
2231+
let progress =
2232+
synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2233+
if let AgentEvent::SubagentProgress {
2234+
status, metadata, ..
2235+
} = progress
2236+
{
2237+
assert_eq!(status, "turn_completed");
2238+
assert_eq!(metadata["turn"], 3);
2239+
assert_eq!(metadata["total_tokens"], 125);
2240+
assert_eq!(metadata["prompt_tokens"], 100);
2241+
assert_eq!(metadata["completion_tokens"], 25);
2242+
} else {
2243+
panic!("expected SubagentProgress");
2244+
}
2245+
}
2246+
2247+
#[test]
2248+
fn synthesize_progress_ignores_unrelated_events() {
2249+
let ignored = [
2250+
AgentEvent::TextDelta {
2251+
text: "hi".to_string(),
2252+
},
2253+
AgentEvent::ToolStart {
2254+
id: "x".to_string(),
2255+
name: "bash".to_string(),
2256+
},
2257+
AgentEvent::TurnStart { turn: 1 },
2258+
AgentEvent::SubagentStart {
2259+
task_id: "nested".to_string(),
2260+
session_id: "nested-run".to_string(),
2261+
parent_session_id: "parent".to_string(),
2262+
agent: "explore".to_string(),
2263+
description: "nested".to_string(),
2264+
},
2265+
];
2266+
for event in &ignored {
2267+
assert!(
2268+
synthesize_subagent_progress(event, "task", "session").is_none(),
2269+
"{:?} should not emit progress",
2270+
event
2271+
);
2272+
}
2273+
}
20852274
}

0 commit comments

Comments
 (0)