Skip to main content

agent_engine/tools/subagent/
resume.rs

1//! SubagentResumeTool — restart a finished or timed-out subagent with new instructions.
2//!
3//! Takes the completed conversation state stored in the prior `SubagentHandle`,
4//! prepends new instructions, and dispatches a fresh subagent via the same flow as
5//! `subagent_start`. The caller gets a new `handle_id` for the continuation run.
6
7use 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        // Extract prior state under the lock, release immediately.
78        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        // The inherited prior model is still re-authorized as an explicit exact
117        // identity; a stale handle cannot bypass current session policy.
118
119        // ── Build resumed task: new instructions → separator → prior context.
120        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        // Restore the original system prompt and timeout from the prior handle
128        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        // ── Build and register handle BEFORE spawning ─────────────────────────
179        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        // ── Spawn subagent thread ──────────────────────────────────────────────
205        let thread_handle = std::thread::spawn(move || {
206            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
207            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                    // Host-built worker (shared client/creds/token cache, cached
235                    // registry) or the legacy fresh runtime — see `spawn_runtime`.
236                    let mut runtime = match super::spawn_runtime().await {
237                        Ok(r) => r,
238                        Err(_) => return Err("subagent runtime initialization failed".into()),
239                    };
240
241                    // Apply subagent spawn policy: worker role, 5m cache TTL, worker
242                    // turn budget. Subagents are short-lived one-shots — paying the 1h
243                    // write premium (~2× input price) on them is unrecoverable waste
244                    // (~$0.23 per 10-spawn fan-out). (#110)
245                    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                    // TTL split: None only if no turn ever reported one; otherwise summed.
272                    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            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
478            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
479            // Sets data.resumed_from so parent can correlate with the prior handle.
480            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        // ── Wire thread handle into the already-registered entry ─────────────
492        {
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(&registry, 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}