1use serde_json::{json, Value};
8use std::sync::atomic::Ordering;
9use std::sync::{Arc, RwLock};
10use std::time::Duration;
11use tokio::sync::{mpsc, oneshot};
12
13use super::super::{Tool, ToolContext, NEXT_SUBAGENT_ID};
14use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
15use crate::{AgentEvent, LlmEvent, Result, RuntimeError, SessionEvent};
16
17pub struct SubagentResumeTool;
18
19fn expired_context_error(handle_id: &str) -> RuntimeError {
20 RuntimeError::Tool(format!(
21 "Subagent '{}' has expired resumable context; call subagent_collect with reconciled=true to reconcile its retained tombstone.",
22 handle_id
23 ))
24}
25
26#[async_trait::async_trait]
27impl Tool for SubagentResumeTool {
28 fn origin(&self) -> crate::tools::ToolOrigin {
29 crate::tools::ToolOrigin::Builtin
30 }
31
32 fn name(&self) -> &str {
33 "subagent_resume"
34 }
35
36 fn description(&self) -> &str {
37 "Resume a finished or timed-out reactive subagent with new instructions. \
38 The previous subagent's conversation state is prepended as context so the \
39 new run has full history. Returns a new handle_id — the original handle \
40 remains readable for comparison. Only works on subagents in \
41 finished/timed_out/failed state."
42 }
43
44 fn parameters(&self) -> Value {
45 json!({
46 "type": "object",
47 "properties": {
48 "handle_id": {
49 "type": "string",
50 "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
51 },
52 "instructions": {
53 "type": "string",
54 "description": "New task or context to prepend to the resumed subagent. \
55 Injected before the prior conversation history."
56 }
57 },
58 "required": ["handle_id", "instructions"]
59 })
60 }
61
62 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
63 let prior_handle_id = params["handle_id"]
64 .as_str()
65 .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
66 .to_string();
67
68 let instructions = params["instructions"]
69 .as_str()
70 .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
71 .to_string();
72
73 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
74 RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
75 })?;
76
77 let (agent_name, model, prior_context, prior_system_prompt, prior_timeout) = {
79 let reg = registry.lock().unwrap();
80 let handle = reg.get(&prior_handle_id).ok_or_else(|| {
81 RuntimeError::Tool(format!(
82 "No subagent found with handle_id '{}'",
83 prior_handle_id
84 ))
85 })?;
86
87 if handle.status() == SubagentStatus::Running {
88 return Err(RuntimeError::Tool(format!(
89 "Subagent '{}' is still running. Call subagent_collect first, \
90 or wait until it finishes.",
91 prior_handle_id
92 )));
93 }
94 if handle.is_tombstone() {
95 return Err(expired_context_error(&prior_handle_id));
96 }
97
98 let prior = {
99 let state = handle.conversation_state();
100 if state.is_empty() {
101 handle.partial_output()
102 } else {
103 serde_json::to_string(&state).unwrap_or_else(|_| handle.partial_output())
104 }
105 };
106
107 (
108 handle.agent_name.clone(),
109 handle.model.clone(),
110 prior,
111 handle.system_prompt.clone(),
112 handle.timeout_secs,
113 )
114 };
115
116 let resumed_task = format!(
121 "{instructions}\n\n\
122 ---\n\
123 [Prior conversation context from handle {prior_handle_id}]\n\
124 {prior_context}"
125 );
126
127 let system_prompt = prior_system_prompt;
129 let timeout_secs = prior_timeout;
130 let label = agent_name.clone();
131 let task_preview: String = resumed_task.chars().take(80).collect();
132 let task_full = resumed_task.clone();
133 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
134 let handle_id = format!("sa_{}", subagent_id);
135 let decision = ctx
136 .capabilities
137 .orchestration
138 .as_ref()
139 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?
140 .resolve_and_authorize(&handle_id, Some(&model))
141 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
142 let model = decision.model.as_str().to_owned();
143 let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
144 let memory_backend = ctx.capabilities.memory_backend.clone();
145
146 tracing::info!(
147 "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
148 label,
149 handle_id,
150 prior_handle_id,
151 model
152 );
153
154 let state = Arc::new(RwLock::new(SubagentState::new()));
155
156 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
157 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
158 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
159
160 if let Some(ref tx) = ctx.channels.tx_events {
161 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
162 subagent_id,
163 agent_name: label.clone(),
164 task_preview: task_preview.clone(),
165 }));
166 }
167
168 let state_t = Arc::clone(&state);
169 let task_full_a = task_full.clone();
170 let label_inner = label.clone();
171 let model_inner = model.clone();
172 let tx_events_inner = ctx.channels.tx_events.clone();
173 let start_time = std::time::Instant::now();
174 let parent_queue = ctx.capabilities.event_queue.clone();
175 let handle_id_inner = handle_id.clone();
176 let prior_handle_for_finalizer = prior_handle_id.clone();
177
178 let system_prompt_for_handle = system_prompt.clone();
180 let handle = SubagentHandle::new(
181 handle_id.clone(),
182 subagent_id,
183 label.clone(),
184 task_preview,
185 model.clone(),
186 system_prompt_for_handle,
187 timeout_secs,
188 Arc::clone(&state),
189 Some(steer_tx),
190 Some(shutdown_tx),
191 Some(result_rx),
192 )
193 .with_authorization(&decision);
194 {
195 let mut reg = registry.lock().unwrap();
196 reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
197 }
198
199 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
200 if let Err(error) = orchestration.mark_starting(&handle_id) {
201 orchestration.rollback(&handle_id);
202 return Err(RuntimeError::Tool(error));
203 }
204 let thread_handle = std::thread::spawn(move || {
206 let state_for_finalizer = Arc::clone(&state_t);
208 let label_for_finalizer = label_inner.clone();
209
210 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
211 let rt = match tokio::runtime::Builder::new_current_thread()
212 .enable_all()
213 .build()
214 {
215 Ok(rt) => rt,
216 Err(_) => {
217 state_t.write().unwrap().status =
218 SubagentStatus::Failed("runtime initialization failed".into());
219 return;
220 }
221 };
222
223 let state_a = Arc::clone(&state_t);
224 let label_a = label_inner.clone();
225 let model_a = model_inner.clone();
226 let tx_events_a = tx_events_inner.clone();
227 let task_for_timeout = task_full_a.clone();
228 let task_for_complete = task_full_a.clone();
229 let task_for_stream = task_full_a;
230
231 let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
232 use futures::StreamExt;
233
234 let mut runtime = match super::spawn_runtime().await {
237 Ok(r) => r,
238 Err(_) => return Err("subagent runtime initialization failed".into()),
239 };
240
241 super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config(), memory_backend.as_ref());
246 runtime.set_system_prompt(super::compose_system_prompt(system_prompt, runtime.memory_backend_is_axel()));
247 runtime.set_model(model_a.clone());
248 super::apply_codex_worker_reasoning(&mut runtime, codex_parent_plan.as_ref());
249
250 let cancel = crate::CancellationToken::new();
251 let cancel_inner = cancel.clone();
252 tokio::spawn(async move {
253 let _ = shutdown_rx.await;
254 cancel_inner.cancel();
255 });
256
257 let mut stream = runtime.run_stream_with_messages(
258 vec![std::sync::Arc::new(serde_json::json!({"role": "user", "content": task_for_stream}))],
259 cancel,
260 Some(steer_rx),
261 None,
262 false,
263 ).await;
264
265 let mut tool_count = 0u32;
266 let mut response_baseline = (0usize, 0usize, 0u32);
267 let mut total_input_tokens = 0u64;
268 let mut total_output_tokens = 0u64;
269 let mut total_cache_read = 0u64;
270 let mut total_cache_creation = 0u64;
271 let mut total_cache_5m: Option<u64> = None;
273 let mut total_cache_1h: Option<u64> = None;
274
275 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
276 tokio::pin!(timeout_fut);
277
278 loop {
279 tokio::select! {
280 event = stream.next() => {
281 let Some(event) = event else { break };
282 match event {
283 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
284 state_a.write().unwrap().note_progress("💭 thinking...", tool_count);
285 if let Some(ref tx) = tx_events_a {
286 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
287 subagent_id,
288 agent_name: label_a.clone(),
289 status: "💭 thinking...".to_string(),
290 }));
291 }
292 }
293 crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
294 let s = state_a.read().unwrap();
295 response_baseline = (s.partial_text.len(), s.tool_log.len(), tool_count);
296 }
297 crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
298 let mut s = state_a.write().unwrap();
299 s.partial_text.truncate(response_baseline.0);
300 s.tool_log.truncate(response_baseline.1);
301 tool_count = response_baseline.2;
302 s.tools = tool_count;
303 }
304 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
305 state_a.write().unwrap().partial_text.push_str(&text);
306 }
307 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
308 tool_count += 1;
309 state_a.write().unwrap().note_progress(&format!("⚙ {} (tool #{})", name, tool_count), tool_count);
310 if let Some(ref tx) = tx_events_a {
311 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
312 subagent_id,
313 agent_name: label_a.clone(),
314 status: format!("⚙ {} (tool #{})", name, tool_count),
315 }));
316 }
317 }
318 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
319 let input_str = input.to_string();
320 let input_preview: String = input_str.chars().take(200).collect();
321 state_a.write().unwrap().tool_log
322 .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
323 let detail = match tool_name.as_str() {
324 "bash" => {
325 let cmd = input["command"].as_str().unwrap_or("");
326 let preview: String = cmd.chars().take(60).collect();
327 format!("$ {}", preview)
328 }
329 "read" => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
330 "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
331 "edit" => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
332 "grep" => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
333 "find" => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
334 "ls" => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
335 other => {
336 if other.starts_with("ext__") {
337 other.splitn(3, "__").last().unwrap_or(other).to_string()
338 } else {
339 other.to_string()
340 }
341 }
342 };
343 state_a.write().unwrap().note_progress(&detail, tool_count);
344 if let Some(ref tx) = tx_events_a {
345 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
346 subagent_id,
347 agent_name: label_a.clone(),
348 status: detail,
349 }));
350 }
351 }
352 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
353 let preview: String = result.chars().take(300).collect();
354 state_a.write().unwrap().tool_log
355 .push(format!("[tool_result]: {}", preview));
356 }
357 crate::StreamEvent::Session(SessionEvent::Usage {
358 input_tokens, output_tokens,
359 cache_read_input_tokens, cache_creation_input_tokens,
360 cache_creation_5m, cache_creation_1h,
361 model: _,
362 }) => {
363 total_input_tokens += input_tokens;
364 total_output_tokens += output_tokens;
365 total_cache_read += cache_read_input_tokens;
366 total_cache_creation += cache_creation_input_tokens;
367 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
368 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
369 }
370 crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(format!("provider request failed [{}]", e.category_label())),
371 crate::StreamEvent::Session(SessionEvent::Done) => break,
372 _ => {}
373 }
374 }
375 _ = &mut timeout_fut => {
376 let (partial, log) = {
377 let mut s = state_a.write().unwrap();
378 s.status = SubagentStatus::TimedOut;
379 s.conversation_state = vec![
380 serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
381 serde_json::json!({"role": "assistant", "content": &s.partial_text}),
382 ];
383 (s.partial_text.clone(), s.tool_log.clone())
384 };
385 let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
386 if !log.is_empty() {
387 text.push_str(&log.join("\n"));
388 text.push('\n');
389 }
390 if !partial.is_empty() {
391 text.push_str("\n[partial response]:\n");
392 text.push_str(&partial);
393 }
394 state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
395 return Ok(SubagentResult {
396 text,
397 model: model_a.clone(),
398 input_tokens: total_input_tokens,
399 output_tokens: total_output_tokens,
400 cache_read: total_cache_read,
401 cache_creation: total_cache_creation,
402 cache_creation_5m: total_cache_5m,
403 cache_creation_1h: total_cache_1h,
404 tool_count,
405 timed_out: true,
406 });
407 }
408 }
409 }
410
411 Ok(SubagentResult {
412 text: state_a.write().unwrap().partial_text.clone(),
413 model: model_a.clone(),
414 input_tokens: total_input_tokens,
415 output_tokens: total_output_tokens,
416 cache_read: total_cache_read,
417 cache_creation: total_cache_creation,
418 cache_creation_5m: total_cache_5m,
419 cache_creation_1h: total_cache_1h,
420 tool_count,
421 timed_out: false,
422 })
423 });
424
425 match outcome {
426 Ok(sa_result) => {
427 {
428 let mut s = state_t.write().unwrap();
429 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
430 s.status = SubagentStatus::Completed;
431 s.conversation_state = vec![
432 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
433 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
434 ];
435 }
436 }
437 let elapsed = start_time.elapsed().as_secs_f64();
438 let preview: String = sa_result.text.chars().take(120).collect();
439 if let Some(ref tx) = tx_events_inner {
440 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
441 subagent_id,
442 agent_name: label_inner.clone(),
443 result_preview: preview,
444 duration_secs: elapsed,
445 }));
446 }
447 let _ = result_tx.send(sa_result);
448 }
449 Err(e) => {
450 state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
451 let elapsed = start_time.elapsed().as_secs_f64();
452 if let Some(ref tx) = tx_events_inner {
453 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
454 subagent_id,
455 agent_name: label_inner.clone(),
456 result_preview: format!("ERROR: {}", e),
457 duration_secs: elapsed,
458 }));
459 }
460 }
461 }
462 }));
463
464 if let Err(panic_info) = panic_result {
465 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
466 s.to_string()
467 } else if let Some(s) = panic_info.downcast_ref::<String>() {
468 s.clone()
469 } else {
470 "unknown panic".to_string()
471 };
472 tracing::error!("Resumed subagent thread panicked: {}", msg);
473 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
474 SubagentStatus::Failed(format!("panic: {}", msg));
475 }
476
477 super::finalize::finalize_subagent(
481 &state_for_finalizer,
482 parent_queue.as_ref(),
483 &handle_id_inner,
484 subagent_id,
485 &label_for_finalizer,
486 start_time,
487 Some(&prior_handle_for_finalizer),
488 );
489 });
490
491 {
493 let mut reg = registry.lock().unwrap();
494 if let Some(h) = reg.get_mut(&handle_id) {
495 h.set_thread_handle(thread_handle);
496 }
497 }
498 if let Err(error) = orchestration.mark_running(&handle_id) {
499 let mut handle = registry.lock().unwrap().remove(&handle_id);
500 if let Some(handle) = handle.as_mut() {
501 handle.cancel();
502 }
503 if let Some(handle) = handle {
504 let _ = handle.collect().await;
505 }
506 orchestration.rollback(&handle_id);
507 return Err(RuntimeError::Tool(error));
508 }
509
510 Ok(json!({
511 "handle_id": handle_id,
512 "resumed_from": prior_handle_id,
513 "agent_name": label,
514 "status": "running"
515 })
516 .to_string())
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
524 use crate::tools::test_helpers::create_tool_context;
525 use std::sync::Mutex;
526
527 #[tokio::test]
528 async fn tombstone_resume_reports_expired_context_without_side_effects() {
529 let state = Arc::new(RwLock::new(SubagentState::new()));
530 {
531 let mut state = state.write().unwrap();
532 state.status = SubagentStatus::Completed;
533 state.partial_text = "terminal output".into();
534 state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
535 state.finished_at = Some(std::time::Instant::now());
536 }
537 let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
538 let (shutdown_tx, _shutdown_rx) = oneshot::channel();
539 let (_result_tx, result_rx) = oneshot::channel();
540 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
541 registry.lock().unwrap().register(SubagentHandle::new(
542 "sa_expired".into(),
543 1,
544 "test".into(),
545 "task".into(),
546 "anthropic/claude-sonnet-4-6".into(),
547 "system".into(),
548 30,
549 state,
550 Some(steer_tx),
551 Some(shutdown_tx),
552 Some(result_rx),
553 ));
554 let foreground =
555 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
556 let orchestration = Arc::new(
557 crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
558 );
559 orchestration
560 .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
561 .unwrap();
562 let gate_before = orchestration.completion_gate();
563 reap_finished_with_ttl(®istry, Some(orchestration.as_ref()), Duration::ZERO);
564 assert!(registry
565 .lock()
566 .unwrap()
567 .get("sa_expired")
568 .unwrap()
569 .is_tombstone());
570 let count_before = registry.lock().unwrap().list_active().len();
571
572 let mut ctx = create_tool_context();
573 ctx.capabilities.subagent_registry = Some(registry.clone());
574 ctx.capabilities.orchestration = Some(orchestration.clone());
575 let error = SubagentResumeTool
576 .execute(
577 json!({"handle_id": "sa_expired", "instructions": "continue"}),
578 ctx,
579 )
580 .await
581 .unwrap_err()
582 .to_string();
583 assert!(error.contains("expired resumable context"));
584 assert!(!error.contains("No subagent found"));
585 assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
586 assert_eq!(orchestration.completion_gate(), gate_before);
587 }
588}