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
144        tracing::info!(
145            "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
146            label,
147            handle_id,
148            prior_handle_id,
149            model
150        );
151
152        let state = Arc::new(RwLock::new(SubagentState::new()));
153
154        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
155        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
156        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
157
158        if let Some(ref tx) = ctx.channels.tx_events {
159            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
160                subagent_id,
161                agent_name: label.clone(),
162                task_preview: task_preview.clone(),
163            }));
164        }
165
166        let state_t = Arc::clone(&state);
167        let task_full_a = task_full.clone();
168        let label_inner = label.clone();
169        let model_inner = model.clone();
170        let tx_events_inner = ctx.channels.tx_events.clone();
171        let start_time = std::time::Instant::now();
172        let parent_queue = ctx.capabilities.event_queue.clone();
173        let handle_id_inner = handle_id.clone();
174        let prior_handle_for_finalizer = prior_handle_id.clone();
175
176        // ── Build and register handle BEFORE spawning ─────────────────────────
177        let system_prompt_for_handle = system_prompt.clone();
178        let handle = SubagentHandle::new(
179            handle_id.clone(),
180            subagent_id,
181            label.clone(),
182            task_preview,
183            model.clone(),
184            system_prompt_for_handle,
185            timeout_secs,
186            Arc::clone(&state),
187            Some(steer_tx),
188            Some(shutdown_tx),
189            Some(result_rx),
190        )
191        .with_authorization(&decision);
192        {
193            let mut reg = registry.lock().unwrap();
194            reg.register(handle);
195        }
196
197        let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
198        if let Err(error) = orchestration.mark_starting(&handle_id) {
199            orchestration.rollback(&handle_id);
200            return Err(RuntimeError::Tool(error));
201        }
202        // ── Spawn subagent thread ──────────────────────────────────────────────
203        let thread_handle = std::thread::spawn(move || {
204            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
205            let state_for_finalizer = Arc::clone(&state_t);
206            let label_for_finalizer = label_inner.clone();
207
208            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
209                let rt = match tokio::runtime::Builder::new_current_thread()
210                    .enable_all()
211                    .build()
212                {
213                    Ok(rt) => rt,
214                    Err(_) => {
215                        state_t.write().unwrap().status =
216                            SubagentStatus::Failed("runtime initialization failed".into());
217                        return;
218                    }
219                };
220
221                let state_a = Arc::clone(&state_t);
222                let label_a = label_inner.clone();
223                let model_a = model_inner.clone();
224                let tx_events_a = tx_events_inner.clone();
225                let task_for_timeout = task_full_a.clone();
226                let task_for_complete = task_full_a.clone();
227                let task_for_stream = task_full_a;
228
229                let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
230                    use futures::StreamExt;
231
232                    let mut runtime = match crate::Runtime::new().await {
233                        Ok(r) => r,
234                        Err(_) => return Err("subagent runtime initialization failed".into()),
235                    };
236
237                    // Apply subagent spawn policy: inherit credential source AND
238                    // unconditionally force cache TTL to 5m. Subagents are short-lived
239                    // one-shots — paying the 1h write premium (~2× input price) on them
240                    // is unrecoverable waste (~$0.23 per 10-spawn fan-out). (#110)
241                    super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
242                    runtime.set_system_prompt(system_prompt);
243                    runtime.set_model(model_a.clone());
244                    runtime.set_tools(super::subagent_tools().await);
245
246                    let cancel = crate::CancellationToken::new();
247                    let cancel_inner = cancel.clone();
248                    tokio::spawn(async move {
249                        let _ = shutdown_rx.await;
250                        cancel_inner.cancel();
251                    });
252
253                    let mut stream = runtime.run_stream_with_messages(
254                        vec![std::sync::Arc::new(serde_json::json!({"role": "user", "content": task_for_stream}))],
255                        cancel,
256                        Some(steer_rx),
257                        None,
258                        false,
259                    ).await;
260
261                    let mut tool_count = 0u32;
262                    let mut total_input_tokens = 0u64;
263                    let mut total_output_tokens = 0u64;
264                    let mut total_cache_read = 0u64;
265                    let mut total_cache_creation = 0u64;
266                    // TTL split: None only if no turn ever reported one; otherwise summed.
267                    let mut total_cache_5m: Option<u64> = None;
268                    let mut total_cache_1h: Option<u64> = None;
269
270                    let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
271                    tokio::pin!(timeout_fut);
272
273                    loop {
274                        tokio::select! {
275                            event = stream.next() => {
276                                let Some(event) = event else { break };
277                                match event {
278                                    crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
279                                        if let Some(ref tx) = tx_events_a {
280                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
281                                                subagent_id,
282                                                agent_name: label_a.clone(),
283                                                status: "💭 thinking...".to_string(),
284                                            }));
285                                        }
286                                    }
287                                    crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
288                                        state_a.write().unwrap().partial_text.push_str(&text);
289                                    }
290                                    crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
291                                        tool_count += 1;
292                                        if let Some(ref tx) = tx_events_a {
293                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
294                                                subagent_id,
295                                                agent_name: label_a.clone(),
296                                                status: format!("⚙ {} (tool #{})", name, tool_count),
297                                            }));
298                                        }
299                                    }
300                                    crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
301                                        let input_str = input.to_string();
302                                        let input_preview: String = input_str.chars().take(200).collect();
303                                        state_a.write().unwrap().tool_log
304                                            .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
305                                        let detail = match tool_name.as_str() {
306                                            "bash" => {
307                                                let cmd = input["command"].as_str().unwrap_or("");
308                                                let preview: String = cmd.chars().take(60).collect();
309                                                format!("$ {}", preview)
310                                            }
311                                            "read"  => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
312                                            "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
313                                            "edit"  => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
314                                            "grep"  => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
315                                            "find"  => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
316                                            "ls"    => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
317                                            other   => {
318                                                if other.starts_with("ext__") {
319                                                    other.splitn(3, "__").last().unwrap_or(other).to_string()
320                                                } else {
321                                                    other.to_string()
322                                                }
323                                            }
324                                        };
325                                        if let Some(ref tx) = tx_events_a {
326                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
327                                                subagent_id,
328                                                agent_name: label_a.clone(),
329                                                status: detail,
330                                            }));
331                                        }
332                                    }
333                                    crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
334                                        let preview: String = result.chars().take(300).collect();
335                                        state_a.write().unwrap().tool_log
336                                            .push(format!("[tool_result]: {}", preview));
337                                    }
338                                    crate::StreamEvent::Session(SessionEvent::Usage {
339                                        input_tokens, output_tokens,
340                                        cache_read_input_tokens, cache_creation_input_tokens,
341                                        cache_creation_5m, cache_creation_1h,
342                                        model: _,
343                                    }) => {
344                                        total_input_tokens    += input_tokens;
345                                        total_output_tokens   += output_tokens;
346                                        total_cache_read      += cache_read_input_tokens;
347                                        total_cache_creation  += cache_creation_input_tokens;
348                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
349                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
350                                    }
351                                    crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(format!("provider request failed [{}]", e.category_label())),
352                                    crate::StreamEvent::Session(SessionEvent::Done) => break,
353                                    _ => {}
354                                }
355                            }
356                            _ = &mut timeout_fut => {
357                                let (partial, log) = {
358                                    let mut s = state_a.write().unwrap();
359                                    s.status = SubagentStatus::TimedOut;
360                                    s.conversation_state = vec![
361                                        serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
362                                        serde_json::json!({"role": "assistant", "content": &s.partial_text}),
363                                    ];
364                                    (s.partial_text.clone(), s.tool_log.clone())
365                                };
366                                let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
367                                if !log.is_empty() {
368                                    text.push_str(&log.join("\n"));
369                                    text.push('\n');
370                                }
371                                if !partial.is_empty() {
372                                    text.push_str("\n[partial response]:\n");
373                                    text.push_str(&partial);
374                                }
375                                state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
376                                return Ok(SubagentResult {
377                                    text,
378                                    model: model_a.clone(),
379                                    input_tokens: total_input_tokens,
380                                    output_tokens: total_output_tokens,
381                                    cache_read: total_cache_read,
382                                    cache_creation: total_cache_creation,
383                                    cache_creation_5m: total_cache_5m,
384                                    cache_creation_1h: total_cache_1h,
385                                    tool_count,
386                                timed_out: true,
387                                });
388                            }
389                        }
390                    }
391
392                    Ok(SubagentResult {
393                        text: state_a.write().unwrap().partial_text.clone(),
394                        model: model_a.clone(),
395                        input_tokens: total_input_tokens,
396                        output_tokens: total_output_tokens,
397                        cache_read: total_cache_read,
398                        cache_creation: total_cache_creation,
399                        cache_creation_5m: total_cache_5m,
400                        cache_creation_1h: total_cache_1h,
401                        tool_count,
402                    timed_out: false,
403                    })
404                });
405
406                match outcome {
407                    Ok(sa_result) => {
408                        {
409                            let mut s = state_t.write().unwrap();
410                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
411                                s.status = SubagentStatus::Completed;
412                                s.conversation_state = vec![
413                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
414                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
415                                ];
416                            }
417                        }
418                        let elapsed = start_time.elapsed().as_secs_f64();
419                        let preview: String = sa_result.text.chars().take(120).collect();
420                        if let Some(ref tx) = tx_events_inner {
421                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
422                                subagent_id,
423                                agent_name: label_inner.clone(),
424                                result_preview: preview,
425                                duration_secs: elapsed,
426                            }));
427                        }
428                        let _ = result_tx.send(sa_result);
429                    }
430                    Err(e) => {
431                        state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
432                        let elapsed = start_time.elapsed().as_secs_f64();
433                        if let Some(ref tx) = tx_events_inner {
434                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
435                                subagent_id,
436                                agent_name: label_inner.clone(),
437                                result_preview: format!("ERROR: {}", e),
438                                duration_secs: elapsed,
439                            }));
440                        }
441                    }
442                }
443            }));
444
445            if let Err(panic_info) = panic_result {
446                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
447                    s.to_string()
448                } else if let Some(s) = panic_info.downcast_ref::<String>() {
449                    s.clone()
450                } else {
451                    "unknown panic".to_string()
452                };
453                tracing::error!("Resumed subagent thread panicked: {}", msg);
454                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
455                    SubagentStatus::Failed(format!("panic: {}", msg));
456            }
457
458            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
459            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
460            // Sets data.resumed_from so parent can correlate with the prior handle.
461            super::finalize::finalize_subagent(
462                &state_for_finalizer,
463                parent_queue.as_ref(),
464                &handle_id_inner,
465                subagent_id,
466                &label_for_finalizer,
467                start_time,
468                Some(&prior_handle_for_finalizer),
469            );
470        });
471
472        // ── Wire thread handle into the already-registered entry ─────────────
473        {
474            let mut reg = registry.lock().unwrap();
475            if let Some(h) = reg.get_mut(&handle_id) {
476                h.set_thread_handle(thread_handle);
477            }
478        }
479        if let Err(error) = orchestration.mark_running(&handle_id) {
480            let mut handle = registry.lock().unwrap().remove(&handle_id);
481            if let Some(handle) = handle.as_mut() {
482                handle.cancel();
483            }
484            if let Some(handle) = handle {
485                let _ = handle.collect().await;
486            }
487            orchestration.rollback(&handle_id);
488            return Err(RuntimeError::Tool(error));
489        }
490
491        Ok(json!({
492            "handle_id":    handle_id,
493            "resumed_from": prior_handle_id,
494            "agent_name":   label,
495            "status":       "running"
496        })
497        .to_string())
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
505    use crate::tools::test_helpers::create_tool_context;
506    use std::sync::Mutex;
507
508    #[tokio::test]
509    async fn tombstone_resume_reports_expired_context_without_side_effects() {
510        let state = Arc::new(RwLock::new(SubagentState::new()));
511        {
512            let mut state = state.write().unwrap();
513            state.status = SubagentStatus::Completed;
514            state.partial_text = "terminal output".into();
515            state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
516            state.finished_at = Some(std::time::Instant::now());
517        }
518        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
519        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
520        let (_result_tx, result_rx) = oneshot::channel();
521        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
522        registry.lock().unwrap().register(SubagentHandle::new(
523            "sa_expired".into(),
524            1,
525            "test".into(),
526            "task".into(),
527            "anthropic/claude-sonnet-4-6".into(),
528            "system".into(),
529            30,
530            state,
531            Some(steer_tx),
532            Some(shutdown_tx),
533            Some(result_rx),
534        ));
535        let foreground =
536            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
537        let orchestration = Arc::new(
538            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
539        );
540        orchestration
541            .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
542            .unwrap();
543        let gate_before = orchestration.completion_gate();
544        reap_finished_with_ttl(&registry, Some(orchestration.as_ref()), Duration::ZERO);
545        assert!(registry
546            .lock()
547            .unwrap()
548            .get("sa_expired")
549            .unwrap()
550            .is_tombstone());
551        let count_before = registry.lock().unwrap().list_active().len();
552
553        let mut ctx = create_tool_context();
554        ctx.capabilities.subagent_registry = Some(registry.clone());
555        ctx.capabilities.orchestration = Some(orchestration.clone());
556        let error = SubagentResumeTool
557            .execute(
558                json!({"handle_id": "sa_expired", "instructions": "continue"}),
559                ctx,
560            )
561            .await
562            .unwrap_err()
563            .to_string();
564        assert!(error.contains("expired resumable context"));
565        assert!(!error.contains("No subagent found"));
566        assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
567        assert_eq!(orchestration.completion_gate(), gate_before);
568    }
569}