Skip to content

Commit 589e363

Browse files
committed
feat(core): expose subagent task tracker for delegated runs
Adds a materialized view over the existing SubagentStart/Progress/End event stream so callers can query the lifecycle of delegated child runs by task id — without scanning run_events() — making automatic parallel delegation observable like a task dashboard. - New `subagent_task_tracker` module with `SubagentTaskSnapshot` + `InMemorySubagentTaskTracker`; populated from runtime events via the existing `RuntimeEventSink::observe` path. - `AgentSession` gains `subagent_task(id)`, `subagent_tasks()`, and `pending_subagent_tasks()` query APIs, scoped to the parent session. - Fixes two pre-existing bugs in `TaskExecutor`: 1. `SubagentStart` now carries the real parent session id instead of `String::new()`, so tracker entries are correctly associated with the originating session. 2. `execute_background` now returns the same task id used in emitted events (previously it generated a throwaway id and `execute` internally generated a different one), and pre-emits `SubagentStart` synchronously so callers can query the tracker immediately after the background task is scheduled. Event stream remains the authoritative record; the tracker is a read-side projection that can be rebuilt by replaying events.
1 parent 7d2ac93 commit 589e363

8 files changed

Lines changed: 565 additions & 25 deletions

File tree

core/src/agent_api.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ pub struct AgentSession {
429429
current_run_id: Arc<tokio::sync::Mutex<Option<String>>>,
430430
/// In-memory run snapshots and event replay buffer for this session.
431431
run_store: Arc<crate::run::InMemoryRunStore>,
432+
/// Materialized view of delegated subagent task lifecycle, populated from runtime events.
433+
subagent_tasks: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
432434
/// Currently executing tools observed from runtime events.
433435
active_tools: Arc<tokio::sync::RwLock<HashMap<String, ActiveToolState>>>,
434436
/// Compact execution traces for this session.
@@ -564,6 +566,34 @@ impl AgentSession {
564566
SessionView::from_session(self).active_tools().await
565567
}
566568

569+
/// Look up a delegated subagent task by id. Returns `None` if no such task
570+
/// has been observed in this session.
571+
pub async fn subagent_task(
572+
&self,
573+
task_id: &str,
574+
) -> Option<crate::subagent_task_tracker::SubagentTaskSnapshot> {
575+
self.subagent_tasks.get(task_id).await
576+
}
577+
578+
/// Return snapshots of every delegated subagent task observed in this
579+
/// session (including completed and failed ones), oldest first.
580+
pub async fn subagent_tasks(&self) -> Vec<crate::subagent_task_tracker::SubagentTaskSnapshot> {
581+
self.subagent_tasks.list_for_parent(&self.session_id).await
582+
}
583+
584+
/// Return snapshots of subagent tasks still in `Running` state.
585+
pub async fn pending_subagent_tasks(
586+
&self,
587+
) -> Vec<crate::subagent_task_tracker::SubagentTaskSnapshot> {
588+
use crate::subagent_task_tracker::SubagentStatus;
589+
self.subagent_tasks
590+
.list_for_parent(&self.session_id)
591+
.await
592+
.into_iter()
593+
.filter(|task| task.status == SubagentStatus::Running)
594+
.collect()
595+
}
596+
567597
/// Return a snapshot of the session's conversation history.
568598
pub fn history(&self) -> Vec<Message> {
569599
SessionView::from_session(self).history()

core/src/agent_api/runtime_events.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub(super) struct RuntimeEventSink {
4747
session_id: String,
4848
hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
4949
active_tools: ActiveToolMap,
50+
subagent_tasks: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
5051
}
5152

5253
impl RuntimeEventSink {
@@ -57,6 +58,7 @@ impl RuntimeEventSink {
5758
session.session_id.clone(),
5859
session.ahp_executor.clone(),
5960
Arc::clone(&session.active_tools),
61+
Arc::clone(&session.subagent_tasks),
6062
)
6163
}
6264

@@ -66,13 +68,15 @@ impl RuntimeEventSink {
6668
session_id: String,
6769
hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
6870
active_tools: ActiveToolMap,
71+
subagent_tasks: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
6972
) -> Self {
7073
Self {
7174
run_store,
7275
run_id,
7376
session_id,
7477
hook_executor,
7578
active_tools,
79+
subagent_tasks,
7680
}
7781
}
7882

@@ -106,7 +110,7 @@ impl RuntimeEventSink {
106110
})
107111
}
108112

109-
async fn observe(&self, event: &AgentEvent) {
113+
pub(super) async fn observe(&self, event: &AgentEvent) {
110114
let _ = self
111115
.run_store
112116
.record_event(&self.run_id, event.clone())
@@ -116,6 +120,7 @@ impl RuntimeEventSink {
116120
.record_agent_event(event, &self.run_id, &self.session_id)
117121
.await;
118122
}
123+
self.subagent_tasks.record_event(event).await;
119124
self.apply(event).await;
120125
}
121126

@@ -200,6 +205,7 @@ mod tests {
200205
"session-1".to_string(),
201206
None,
202207
Arc::clone(&active_tools),
208+
Arc::new(crate::subagent_task_tracker::InMemorySubagentTaskTracker::new()),
203209
);
204210

205211
sink.observe(&AgentEvent::ToolStart {
@@ -239,6 +245,7 @@ mod tests {
239245
"session-1".to_string(),
240246
None,
241247
active_tools(),
248+
Arc::new(crate::subagent_task_tracker::InMemorySubagentTaskTracker::new()),
242249
);
243250

244251
sink.observe(&AgentEvent::TextDelta {

core/src/agent_api/session_builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ pub(super) fn build_agent_session(
219219
cancel_token: Arc::new(tokio::sync::Mutex::new(None)),
220220
current_run_id: Arc::new(tokio::sync::Mutex::new(None)),
221221
run_store: Arc::new(crate::run::InMemoryRunStore::new()),
222+
subagent_tasks: Arc::new(crate::subagent_task_tracker::InMemorySubagentTaskTracker::new()),
222223
active_tools: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
223224
trace_sink,
224225
verification_reports: Arc::new(RwLock::new(Vec::new())),

core/src/agent_api/tests.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2324,3 +2324,96 @@ fn test_session_command_is_available_from_queue_module() {
23242324
use crate::queue::SessionCommand;
23252325
let _ = std::marker::PhantomData::<Box<dyn SessionCommand>>;
23262326
}
2327+
2328+
#[tokio::test]
2329+
async fn subagent_events_populate_session_tracker() {
2330+
use super::runtime_events::RuntimeEventSink;
2331+
use crate::agent::AgentEvent;
2332+
use crate::subagent_task_tracker::SubagentStatus;
2333+
2334+
let agent = Agent::from_config(test_config()).await.unwrap();
2335+
let session = agent
2336+
.session("/tmp/test-ws-subagent-tracker", None)
2337+
.unwrap();
2338+
2339+
// Drive a synthetic subagent lifecycle through the session's runtime sink.
2340+
let run = session
2341+
.run_store
2342+
.create_run(session.session_id(), "parent prompt")
2343+
.await;
2344+
let sink = RuntimeEventSink::from_session(&session, &run.id);
2345+
2346+
let task_id = "task-test-1".to_string();
2347+
let child_session_id = format!("task-run-{}", task_id);
2348+
2349+
sink.observe(&AgentEvent::SubagentStart {
2350+
task_id: task_id.clone(),
2351+
session_id: child_session_id.clone(),
2352+
parent_session_id: session.session_id().to_string(),
2353+
agent: "explore".to_string(),
2354+
description: "demo delegation".to_string(),
2355+
})
2356+
.await;
2357+
2358+
let snap = session
2359+
.subagent_task(&task_id)
2360+
.await
2361+
.expect("running task should be visible");
2362+
assert_eq!(snap.status, SubagentStatus::Running);
2363+
assert_eq!(snap.parent_session_id, session.session_id());
2364+
assert_eq!(snap.child_session_id, child_session_id);
2365+
assert_eq!(snap.agent, "explore");
2366+
assert!(snap.finished_ms.is_none());
2367+
2368+
let pending = session.pending_subagent_tasks().await;
2369+
assert_eq!(pending.len(), 1);
2370+
assert_eq!(pending[0].task_id, task_id);
2371+
2372+
sink.observe(&AgentEvent::SubagentEnd {
2373+
task_id: task_id.clone(),
2374+
session_id: child_session_id,
2375+
agent: "explore".to_string(),
2376+
output: "found things".to_string(),
2377+
success: true,
2378+
})
2379+
.await;
2380+
2381+
let snap = session.subagent_task(&task_id).await.unwrap();
2382+
assert_eq!(snap.status, SubagentStatus::Completed);
2383+
assert_eq!(snap.success, Some(true));
2384+
assert_eq!(snap.output.as_deref(), Some("found things"));
2385+
assert!(snap.finished_ms.is_some());
2386+
2387+
assert!(session.pending_subagent_tasks().await.is_empty());
2388+
assert_eq!(session.subagent_tasks().await.len(), 1);
2389+
}
2390+
2391+
#[tokio::test]
2392+
async fn subagent_tasks_scope_to_parent_session() {
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_a = agent.session("/tmp/test-ws-subagent-a", None).unwrap();
2398+
let session_b = agent.session("/tmp/test-ws-subagent-b", None).unwrap();
2399+
2400+
let run = session_a
2401+
.run_store
2402+
.create_run(session_a.session_id(), "p")
2403+
.await;
2404+
let sink = RuntimeEventSink::from_session(&session_a, &run.id);
2405+
2406+
sink.observe(&AgentEvent::SubagentStart {
2407+
task_id: "task-from-a".to_string(),
2408+
session_id: "task-run-task-from-a".to_string(),
2409+
parent_session_id: session_a.session_id().to_string(),
2410+
agent: "explore".to_string(),
2411+
description: "isolated".to_string(),
2412+
})
2413+
.await;
2414+
2415+
// session A sees the task; session B has its own (empty) tracker.
2416+
assert_eq!(session_a.subagent_tasks().await.len(), 1);
2417+
assert!(session_b.subagent_tasks().await.is_empty());
2418+
assert!(session_b.subagent_task("task-from-a").await.is_none());
2419+
}

core/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ pub(crate) mod session_lane_queue;
106106
pub mod skills;
107107
pub mod store;
108108
pub mod subagent;
109+
pub mod subagent_task_tracker;
109110
pub mod telemetry;
110111
#[cfg(feature = "telemetry")]
111112
pub mod telemetry_otel;
@@ -138,6 +139,9 @@ pub use subagent::{
138139
AgentDefinition, AgentRegistry, CattleAgentKind, CattleAgentSpec, ConfirmationInheritance,
139140
WorkerAgentKind, WorkerAgentSpec,
140141
};
142+
pub use subagent_task_tracker::{
143+
InMemorySubagentTaskTracker, SubagentProgressEntry, SubagentStatus, SubagentTaskSnapshot,
144+
};
141145
pub use tools::ToolErrorKind;
142146
pub use workspace::{
143147
CommandOutput, CommandOutputObserver, CommandRequest, LocalWorkspaceBackend, RemoteGitBackend,

0 commit comments

Comments
 (0)