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 crate::{Result, RuntimeError, LlmEvent, SessionEvent, AgentEvent};
14use super::super::{Tool, ToolContext, NEXT_SUBAGENT_ID};
15use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentStatus, SubagentState};
16
17pub struct SubagentResumeTool;
18
19#[async_trait::async_trait]
20impl Tool for SubagentResumeTool {
21    fn name(&self) -> &str { "subagent_resume" }
22
23    fn description(&self) -> &str {
24        "Resume a finished or timed-out reactive subagent with new instructions. \
25         The previous subagent's conversation state is prepended as context so the \
26         new run has full history. Returns a new handle_id — the original handle \
27         remains readable for comparison. Only works on subagents in \
28         finished/timed_out/failed state."
29    }
30
31    fn parameters(&self) -> Value {
32        json!({
33            "type": "object",
34            "properties": {
35                "handle_id": {
36                    "type": "string",
37                    "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
38                },
39                "instructions": {
40                    "type": "string",
41                    "description": "New task or context to prepend to the resumed subagent. \
42                                    Injected before the prior conversation history."
43                }
44            },
45            "required": ["handle_id", "instructions"]
46        })
47    }
48
49    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
50        let prior_handle_id = params["handle_id"].as_str()
51            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
52            .to_string();
53
54        let instructions = params["instructions"].as_str()
55            .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
56            .to_string();
57
58        let registry = ctx.capabilities.subagent_registry.as_ref()
59            .ok_or_else(|| RuntimeError::Tool(
60                "SubagentRegistry not available on this ToolContext".to_string()
61            ))?;
62
63        // Extract prior state under the lock, release immediately.
64        let (agent_name, model, prior_context, prior_system_prompt, prior_timeout) = {
65            let reg = registry.lock().unwrap();
66            let handle = reg.get(&prior_handle_id)
67                .ok_or_else(|| RuntimeError::Tool(
68                    format!("No subagent found with handle_id '{}'", prior_handle_id)
69                ))?;
70
71            if handle.status() == SubagentStatus::Running {
72                return Err(RuntimeError::Tool(format!(
73                    "Subagent '{}' is still running. Call subagent_collect first, \
74                     or wait until it finishes.",
75                    prior_handle_id
76                )));
77            }
78
79            let prior = {
80                let state = handle.conversation_state();
81                if state.is_empty() {
82                    handle.partial_output()
83                } else {
84                    serde_json::to_string(&state).unwrap_or_else(|_| handle.partial_output())
85                }
86            };
87
88            (handle.agent_name.clone(), handle.model.clone(), prior, handle.system_prompt.clone(), handle.timeout_secs)
89        };
90
91        // ── Build resumed task: new instructions → separator → prior context.
92        let resumed_task = format!(
93            "{instructions}\n\n\
94             ---\n\
95             [Prior conversation context from handle {prior_handle_id}]\n\
96             {prior_context}"
97        );
98
99        // Restore the original system prompt and timeout from the prior handle
100        let system_prompt = prior_system_prompt;
101        let timeout_secs = prior_timeout;
102        let label = agent_name.clone();
103        let task_preview: String = resumed_task.chars().take(80).collect();
104        let task_full = resumed_task.clone();
105        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
106        let handle_id = format!("sa_{}", subagent_id);
107
108        tracing::info!(
109            "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
110            label, handle_id, prior_handle_id, model
111        );
112
113        let state = Arc::new(RwLock::new(SubagentState::new()));
114
115        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
116        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
117        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
118
119        if let Some(ref tx) = ctx.channels.tx_events {
120            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
121                subagent_id,
122                agent_name: label.clone(),
123                task_preview: task_preview.clone(),
124            }));
125        }
126
127        let state_t         = Arc::clone(&state);
128        let task_full_a     = task_full.clone();
129        let label_inner     = label.clone();
130        let model_inner     = model.clone();
131        let tx_events_inner = ctx.channels.tx_events.clone();
132        let start_time      = std::time::Instant::now();
133        let parent_queue    = ctx.capabilities.event_queue.clone();
134        let handle_id_inner = handle_id.clone();
135        let prior_handle_for_finalizer = prior_handle_id.clone();
136
137        // ── Build and register handle BEFORE spawning ─────────────────────────
138        let system_prompt_for_handle = system_prompt.clone();
139        let handle = SubagentHandle::new(
140            handle_id.clone(),
141            subagent_id,
142            label.clone(),
143            task_preview,
144            model.clone(),
145            system_prompt_for_handle,
146            timeout_secs,
147            Arc::clone(&state),
148            Some(steer_tx),
149            Some(shutdown_tx),
150            Some(result_rx),
151        );
152        {
153            let mut reg = registry.lock().unwrap();
154            reg.register(handle);
155        }
156
157        // ── Spawn subagent thread ──────────────────────────────────────────────
158        let thread_handle = std::thread::spawn(move || {
159            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
160            let state_for_finalizer = Arc::clone(&state_t);
161            let label_for_finalizer = label_inner.clone();
162
163            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
164                let rt = match tokio::runtime::Builder::new_current_thread()
165                    .enable_all()
166                    .build()
167                {
168                    Ok(rt) => rt,
169                    Err(e) => {
170                        state_t.write().unwrap().status =
171                            SubagentStatus::Failed(format!("tokio runtime: {}", e));
172                        return;
173                    }
174                };
175
176                let state_a           = Arc::clone(&state_t);
177                let label_a           = label_inner.clone();
178                let model_a           = model_inner.clone();
179                let tx_events_a       = tx_events_inner.clone();
180                let task_for_timeout  = task_full_a.clone();
181                let task_for_complete = task_full_a.clone();
182                let task_for_stream   = task_full_a;
183
184                let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
185                    use futures::StreamExt;
186
187                    let mut runtime = match crate::Runtime::new().await {
188                        Ok(r) => r,
189                        Err(e) => return Err(format!("Failed to create subagent runtime: {}", e)),
190                    };
191
192                    // Apply subagent spawn policy: inherit credential source AND
193                    // unconditionally force cache TTL to 5m. Subagents are short-lived
194                    // one-shots — paying the 1h write premium (~2× input price) on them
195                    // is unrecoverable waste (~$0.23 per 10-spawn fan-out). (#110)
196                    super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
197                    runtime.set_system_prompt(system_prompt);
198                    runtime.set_model(model_a.clone());
199                    runtime.set_tools(super::subagent_tools().await);
200
201                    let cancel = crate::CancellationToken::new();
202                    let cancel_inner = cancel.clone();
203                    tokio::spawn(async move {
204                        let _ = shutdown_rx.await;
205                        cancel_inner.cancel();
206                    });
207
208                    let mut stream = runtime.run_stream_with_messages(
209                        vec![std::sync::Arc::new(serde_json::json!({"role": "user", "content": task_for_stream}))],
210                        cancel,
211                        Some(steer_rx),
212                        None,
213                        false,
214                    ).await;
215
216                    let mut tool_count = 0u32;
217                    let mut total_input_tokens = 0u64;
218                    let mut total_output_tokens = 0u64;
219                    let mut total_cache_read = 0u64;
220                    let mut total_cache_creation = 0u64;
221                    // TTL split: None only if no turn ever reported one; otherwise summed.
222                    let mut total_cache_5m: Option<u64> = None;
223                    let mut total_cache_1h: Option<u64> = None;
224
225                    let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
226                    tokio::pin!(timeout_fut);
227
228                    loop {
229                        tokio::select! {
230                            event = stream.next() => {
231                                let Some(event) = event else { break };
232                                match event {
233                                    crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
234                                        if let Some(ref tx) = tx_events_a {
235                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
236                                                subagent_id,
237                                                agent_name: label_a.clone(),
238                                                status: "💭 thinking...".to_string(),
239                                            }));
240                                        }
241                                    }
242                                    crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
243                                        state_a.write().unwrap().partial_text.push_str(&text);
244                                    }
245                                    crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
246                                        tool_count += 1;
247                                        if let Some(ref tx) = tx_events_a {
248                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
249                                                subagent_id,
250                                                agent_name: label_a.clone(),
251                                                status: format!("⚙ {} (tool #{})", name, tool_count),
252                                            }));
253                                        }
254                                    }
255                                    crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
256                                        let input_str = input.to_string();
257                                        let input_preview: String = input_str.chars().take(200).collect();
258                                        state_a.write().unwrap().tool_log
259                                            .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
260                                        let detail = match tool_name.as_str() {
261                                            "bash" => {
262                                                let cmd = input["command"].as_str().unwrap_or("");
263                                                let preview: String = cmd.chars().take(60).collect();
264                                                format!("$ {}", preview)
265                                            }
266                                            "read"  => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
267                                            "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
268                                            "edit"  => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
269                                            "grep"  => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
270                                            "find"  => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
271                                            "ls"    => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
272                                            other   => {
273                                                if other.starts_with("ext__") {
274                                                    other.splitn(3, "__").last().unwrap_or(other).to_string()
275                                                } else {
276                                                    other.to_string()
277                                                }
278                                            }
279                                        };
280                                        if let Some(ref tx) = tx_events_a {
281                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
282                                                subagent_id,
283                                                agent_name: label_a.clone(),
284                                                status: detail,
285                                            }));
286                                        }
287                                    }
288                                    crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
289                                        let preview: String = result.chars().take(300).collect();
290                                        state_a.write().unwrap().tool_log
291                                            .push(format!("[tool_result]: {}", preview));
292                                    }
293                                    crate::StreamEvent::Session(SessionEvent::Usage {
294                                        input_tokens, output_tokens,
295                                        cache_read_input_tokens, cache_creation_input_tokens,
296                                        cache_creation_5m, cache_creation_1h,
297                                        model: _,
298                                    }) => {
299                                        total_input_tokens    += input_tokens;
300                                        total_output_tokens   += output_tokens;
301                                        total_cache_read      += cache_read_input_tokens;
302                                        total_cache_creation  += cache_creation_input_tokens;
303                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
304                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
305                                    }
306                                    crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(e),
307                                    crate::StreamEvent::Session(SessionEvent::Done) => break,
308                                    _ => {}
309                                }
310                            }
311                            _ = &mut timeout_fut => {
312                                let (partial, log) = {
313                                    let mut s = state_a.write().unwrap();
314                                    s.status = SubagentStatus::TimedOut;
315                                    s.conversation_state = vec![
316                                        serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
317                                        serde_json::json!({"role": "assistant", "content": &s.partial_text}),
318                                    ];
319                                    (s.partial_text.clone(), s.tool_log.clone())
320                                };
321                                let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
322                                if !log.is_empty() {
323                                    text.push_str(&log.join("\n"));
324                                    text.push('\n');
325                                }
326                                if !partial.is_empty() {
327                                    text.push_str("\n[partial response]:\n");
328                                    text.push_str(&partial);
329                                }
330                                state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
331                                return Ok(SubagentResult {
332                                    text,
333                                    model: model_a.clone(),
334                                    input_tokens: total_input_tokens,
335                                    output_tokens: total_output_tokens,
336                                    cache_read: total_cache_read,
337                                    cache_creation: total_cache_creation,
338                                    cache_creation_5m: total_cache_5m,
339                                    cache_creation_1h: total_cache_1h,
340                                    tool_count,
341                                });
342                            }
343                        }
344                    }
345
346                    Ok(SubagentResult {
347                        text: state_a.write().unwrap().partial_text.clone(),
348                        model: model_a.clone(),
349                        input_tokens: total_input_tokens,
350                        output_tokens: total_output_tokens,
351                        cache_read: total_cache_read,
352                        cache_creation: total_cache_creation,
353                        cache_creation_5m: total_cache_5m,
354                        cache_creation_1h: total_cache_1h,
355                        tool_count,
356                    })
357                });
358
359                match outcome {
360                    Ok(sa_result) => {
361                        {
362                            let mut s = state_t.write().unwrap();
363                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
364                                s.status = SubagentStatus::Completed;
365                                s.conversation_state = vec![
366                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
367                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
368                                ];
369                            }
370                        }
371                        let elapsed = start_time.elapsed().as_secs_f64();
372                        let preview: String = sa_result.text.chars().take(120).collect();
373                        if let Some(ref tx) = tx_events_inner {
374                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
375                                subagent_id,
376                                agent_name: label_inner.clone(),
377                                result_preview: preview,
378                                duration_secs: elapsed,
379                            }));
380                        }
381                        let _ = result_tx.send(sa_result);
382                    }
383                    Err(e) => {
384                        state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
385                        let elapsed = start_time.elapsed().as_secs_f64();
386                        if let Some(ref tx) = tx_events_inner {
387                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
388                                subagent_id,
389                                agent_name: label_inner.clone(),
390                                result_preview: format!("ERROR: {}", e),
391                                duration_secs: elapsed,
392                            }));
393                        }
394                    }
395                }
396            }));
397
398            if let Err(panic_info) = panic_result {
399                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
400                    s.to_string()
401                } else if let Some(s) = panic_info.downcast_ref::<String>() {
402                    s.clone()
403                } else {
404                    "unknown panic".to_string()
405                };
406                tracing::error!("Resumed subagent thread panicked: {}", msg);
407                state_t.write().unwrap_or_else(|p| p.into_inner()).status = SubagentStatus::Failed(format!("panic: {}", msg));
408            }
409
410            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
411            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
412            // Sets data.resumed_from so parent can correlate with the prior handle.
413            super::finalize::finalize_subagent(
414                &state_for_finalizer,
415                parent_queue.as_ref(),
416                &handle_id_inner,
417                subagent_id,
418                &label_for_finalizer,
419                start_time,
420                Some(&prior_handle_for_finalizer),
421            );
422        });
423
424        // ── Wire thread handle into the already-registered entry ─────────────
425        {
426            let mut reg = registry.lock().unwrap();
427            if let Some(h) = reg.get_mut(&handle_id) {
428                h.set_thread_handle(thread_handle);
429            }
430        }
431
432        Ok(json!({
433            "handle_id":    handle_id,
434            "resumed_from": prior_handle_id,
435            "agent_name":   label,
436            "status":       "running"
437        }).to_string())
438    }
439}