1use async_trait::async_trait;
2use serde_json::json;
3use std::collections::{BTreeSet, HashMap};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, LazyLock, Mutex};
6use tokio::sync::Semaphore;
7use tracing::{info, warn};
8
9use super::{
10 auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolAuthContext,
11 ToolRegistry, ToolResult,
12};
13use crate::config::{Config, ResolvedSubagentAcpTargetConfig};
14use crate::internal::channels::channel::deliver_and_store_bot_message;
15use crate::internal::channels::channel_adapter::ChannelRegistry;
16use crate::internal::storage::db::{
17 call_blocking, CreateSubagentRunParams, Database, FinishSubagentRunParams,
18};
19use microclaw_core::llm_types::{
20 ContentBlock, Message, MessageContent, ResponseContentBlock, ToolDefinition,
21};
22
23const MAX_SUB_AGENT_ITERATIONS: usize = 16;
24
25#[derive(Debug, Clone, Copy)]
26enum SubagentExecutionRuntime {
27 Native,
28 Acp,
29}
30
31impl SubagentExecutionRuntime {
32 fn from_raw(raw: Option<&str>) -> Result<Self, String> {
33 match raw.unwrap_or("native").trim().to_ascii_lowercase().as_str() {
34 "" | "native" => Ok(Self::Native),
35 "acp" => Ok(Self::Acp),
36 other => Err(format!(
37 "Unsupported subagent runtime '{other}'. Expected 'native' or 'acp'."
38 )),
39 }
40 }
41
42 fn from_input(
43 input: &serde_json::Value,
44 parent_meta: Option<&SubagentRuntimeMeta>,
45 ) -> Result<Self, String> {
46 let runtime = input
47 .get("runtime")
48 .and_then(|v| v.as_str())
49 .or_else(|| parent_meta.and_then(|meta| meta.runtime.as_deref()));
50 Self::from_raw(runtime)
51 }
52
53 fn as_str(&self) -> &'static str {
54 match self {
55 Self::Native => "native",
56 Self::Acp => "acp",
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
62struct SubagentRuntimeMeta {
63 depth: i64,
64 token_budget_remaining: Option<i64>,
65 runtime: Option<String>,
66 runtime_target: Option<String>,
67}
68
69fn subagent_runtime_meta_from_input(input: &serde_json::Value) -> Option<SubagentRuntimeMeta> {
70 let meta = input.get("__subagent_runtime")?;
71 let depth = meta.get("depth").and_then(|v| v.as_i64()).unwrap_or(0);
72 let token_budget_remaining = meta
73 .get("token_budget_remaining")
74 .and_then(|v| v.as_i64())
75 .filter(|v| *v > 0);
76 let runtime = meta
77 .get("runtime")
78 .and_then(|v| v.as_str())
79 .map(str::trim)
80 .filter(|v| !v.is_empty())
81 .map(ToOwned::to_owned);
82 let runtime_target = meta
83 .get("runtime_target")
84 .and_then(|v| v.as_str())
85 .map(str::trim)
86 .filter(|v| !v.is_empty())
87 .map(ToOwned::to_owned);
88 Some(SubagentRuntimeMeta {
89 depth,
90 token_budget_remaining,
91 runtime,
92 runtime_target,
93 })
94}
95
96fn compute_child_token_budget(
97 requested_budget: Option<i64>,
98 parent_budget_remaining: Option<i64>,
99 configured_max: i64,
100) -> Result<i64, String> {
101 let configured_max = configured_max.clamp(2_000, 2_000_000);
102 if let Some(parent_remaining) = parent_budget_remaining {
103 if parent_remaining < 2_000 {
104 return Err(format!(
105 "subagent budget exhausted: parent remaining {} < 2000",
106 parent_remaining
107 ));
108 }
109 let desired = requested_budget.unwrap_or((parent_remaining / 2).max(2_000));
110 return Ok(desired.clamp(2_000, parent_remaining.min(configured_max)));
111 }
112 let desired = requested_budget.unwrap_or(configured_max);
113 Ok(desired.clamp(2_000, configured_max))
114}
115
116const SUBMIT_RESULT_TOOL: &str = "submit_result";
121
122fn submit_result_tool_definition() -> ToolDefinition {
125 ToolDefinition {
126 name: SUBMIT_RESULT_TOOL.to_string(),
127 description:
128 "Submit your final structured result and end the run. Call this exactly once when the \
129 task is complete, instead of writing the result as plain text."
130 .to_string(),
131 input_schema: json!({
132 "type": "object",
133 "properties": {
134 "summary": {"type": "string", "description": "One-line summary of what you did."},
135 "findings": {"type": "array", "items": {"type": "string"}, "description": "Key findings or results."},
136 "artifacts": {"type": "array", "items": {"type": "object"}, "description": "Files/artifacts produced, each {type, path, description}."},
137 "next_actions": {"type": "array", "items": {"type": "string"}, "description": "Suggested follow-up actions."},
138 "final_answer": {"type": "string", "description": "The full answer delivered to the user."}
139 },
140 "required": ["summary", "final_answer"]
141 }),
142 }
143}
144
145pub(crate) fn subagent_output_is_structured(raw_text: &str) -> bool {
151 let cleaned = crate::agent_engine::sanitize_user_visible_text(raw_text);
152 let text = cleaned.trim();
153 let parsed = serde_json::from_str::<serde_json::Value>(text)
154 .ok()
155 .or_else(|| {
156 let start = text.find('{')?;
157 let end = text.rfind('}')?;
158 if end > start {
159 serde_json::from_str::<serde_json::Value>(&text[start..=end]).ok()
160 } else {
161 None
162 }
163 });
164 parsed
165 .as_ref()
166 .and_then(|v| v.as_object())
167 .map(|obj| {
168 ["final_answer", "summary"].iter().any(|k| {
169 obj.get(*k)
170 .and_then(|v| v.as_str())
171 .map(|s| !s.trim().is_empty())
172 .unwrap_or(false)
173 })
174 })
175 .unwrap_or(false)
176}
177
178pub(crate) fn normalize_subagent_artifact_payload(raw_text: &str) -> (String, String) {
179 let cleaned = crate::agent_engine::sanitize_user_visible_text(raw_text);
183 let text = cleaned.trim();
184 let parsed = serde_json::from_str::<serde_json::Value>(text)
188 .ok()
189 .or_else(|| {
190 let start = text.find('{')?;
191 let end = text.rfind('}')?;
192 if end > start {
193 serde_json::from_str::<serde_json::Value>(&text[start..=end]).ok()
194 } else {
195 None
196 }
197 });
198 let mut summary = String::new();
199 let mut final_answer = String::new();
200 let mut findings: Vec<String> = Vec::new();
201 let mut next_actions: Vec<String> = Vec::new();
202 let mut artifacts: Vec<serde_json::Value> = Vec::new();
203 if let Some(obj) = parsed.as_ref().and_then(|v| v.as_object()) {
204 summary = obj
205 .get("summary")
206 .and_then(|v| v.as_str())
207 .unwrap_or("")
208 .trim()
209 .to_string();
210 final_answer = obj
211 .get("final_answer")
212 .and_then(|v| v.as_str())
213 .unwrap_or("")
214 .trim()
215 .to_string();
216 if let Some(arr) = obj.get("findings").and_then(|v| v.as_array()) {
217 findings = arr
218 .iter()
219 .filter_map(|v| v.as_str())
220 .map(str::trim)
221 .filter(|v| !v.is_empty())
222 .map(ToOwned::to_owned)
223 .collect();
224 }
225 if let Some(arr) = obj.get("next_actions").and_then(|v| v.as_array()) {
226 next_actions = arr
227 .iter()
228 .filter_map(|v| v.as_str())
229 .map(str::trim)
230 .filter(|v| !v.is_empty())
231 .map(ToOwned::to_owned)
232 .collect();
233 }
234 if let Some(arr) = obj.get("artifacts").and_then(|v| v.as_array()) {
235 artifacts = arr.clone();
236 }
237 }
238 if summary.is_empty() {
239 summary = text
240 .chars()
241 .take(600)
242 .collect::<String>()
243 .trim()
244 .to_string();
245 }
246 if final_answer.is_empty() {
247 final_answer = summary.clone();
248 }
249 let envelope = json!({
250 "protocol": "subagent_artifact_v1",
251 "summary": summary,
252 "findings": findings,
253 "artifacts": artifacts,
254 "next_actions": next_actions,
255 "final_answer": final_answer,
256 "raw_text": text,
257 });
258 let answer_text = envelope
259 .get("final_answer")
260 .and_then(|v| v.as_str())
261 .unwrap_or("(sub-agent produced no output)")
262 .to_string();
263 (answer_text, envelope.to_string())
264}
265
266struct SubagentRuntime {
267 semaphore: Semaphore,
268 cancel_flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
269}
270
271impl SubagentRuntime {
272 fn new(max_concurrent: usize) -> Self {
273 Self {
274 semaphore: Semaphore::new(max_concurrent.max(1)),
275 cancel_flags: Mutex::new(HashMap::new()),
276 }
277 }
278
279 fn register_run(&self, run_id: &str) -> Arc<AtomicBool> {
280 let flag = Arc::new(AtomicBool::new(false));
281 if let Ok(mut guard) = self.cancel_flags.lock() {
282 guard.insert(run_id.to_string(), flag.clone());
283 }
284 flag
285 }
286
287 fn cancel_run(&self, run_id: &str) {
288 if let Ok(guard) = self.cancel_flags.lock() {
289 if let Some(flag) = guard.get(run_id) {
290 flag.store(true, Ordering::Relaxed);
291 }
292 }
293 }
294
295 fn remove_run(&self, run_id: &str) {
296 if let Ok(mut guard) = self.cancel_flags.lock() {
297 guard.remove(run_id);
298 }
299 }
300}
301
302static RUNTIME: LazyLock<Mutex<Option<Arc<SubagentRuntime>>>> = LazyLock::new(|| Mutex::new(None));
303
304static TASK_RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
310 tokio::runtime::Builder::new_multi_thread()
311 .enable_all()
312 .thread_name("microclaw-subagent")
313 .build()
314 .expect("failed to create process-lifetime subagent runtime")
315});
316
317fn subagent_runtime(config: &Config) -> Arc<SubagentRuntime> {
318 let mut guard = match RUNTIME.lock() {
319 Ok(g) => g,
320 Err(poisoned) => poisoned.into_inner(),
321 };
322 if let Some(existing) = guard.as_ref() {
323 return existing.clone();
324 }
325 let runtime = Arc::new(SubagentRuntime::new(config.subagents.max_concurrent));
326 *guard = Some(runtime.clone());
327 runtime
328}
329
330pub fn request_subagent_cancel(
334 config: &Config,
335 db: &Database,
336 chat_id: i64,
337 run_id: &str,
338) -> Result<bool, microclaw_core::error::MicroClawError> {
339 let requested = db.request_subagent_cancel(run_id, chat_id)?;
340 if requested {
341 subagent_runtime(config).cancel_run(run_id);
342 }
343 Ok(requested)
344}
345
346async fn log_subagent_event(
347 db: Arc<Database>,
348 run_id: &str,
349 event_type: &str,
350 detail: Option<String>,
351) {
352 let run_id = run_id.to_string();
353 let event_type = event_type.to_string();
354 let _ = call_blocking(db, move |db| {
355 db.append_subagent_event(&run_id, &event_type, detail.as_deref())
356 })
357 .await;
358}
359
360pub(crate) async fn is_cancelled(
361 db: Arc<Database>,
362 run_id: &str,
363 local_flag: &Arc<AtomicBool>,
364) -> Result<bool, String> {
365 if local_flag.load(Ordering::Relaxed) {
366 return Ok(true);
367 }
368 let run_id_owned = run_id.to_string();
369 let db_cancel = call_blocking(db, move |db| db.is_subagent_cancel_requested(&run_id_owned))
370 .await
371 .map_err(|e| format!("Failed checking cancel state: {e}"))?;
372 Ok(db_cancel)
373}
374
375struct RunSubAgentTaskParams {
376 config: Config,
377 db: Arc<Database>,
378 channel_registry: Arc<ChannelRegistry>,
379 auth_context: ToolAuthContext,
380 run_id: String,
381 runtime: SubagentExecutionRuntime,
382 acp_target: Option<ResolvedSubagentAcpTargetConfig>,
383 depth: i64,
384 run_token_budget: i64,
385 task: String,
386 context: String,
387 specialist: String,
388 exit_criteria: Vec<crate::completion_contract::ExitCriterion>,
389 local_cancel: Arc<AtomicBool>,
390}
391
392struct SubagentBashRunner<'a> {
396 tools: &'a ToolRegistry,
397 auth: &'a ToolAuthContext,
398}
399
400#[async_trait::async_trait]
401impl crate::completion_contract::CommandRunner for SubagentBashRunner<'_> {
402 async fn run(&self, command: &str) -> (bool, String) {
403 let result = self
404 .tools
405 .execute_with_auth("bash", json!({ "command": command }), self.auth)
406 .await;
407 (!result.is_error, result.content)
408 }
409}
410
411struct ContractVerdict {
413 annotated: String,
415 report: String,
417 passed: bool,
418}
419
420#[allow(clippy::too_many_arguments)]
425async fn apply_completion_contract(
426 criteria: &[crate::completion_contract::ExitCriterion],
427 final_text: &str,
428 config: &Config,
429 tools: &ToolRegistry,
430 auth: &ToolAuthContext,
431 db: Arc<Database>,
432 run_id: &str,
433) -> Option<ContractVerdict> {
434 if criteria.is_empty() {
435 return None;
436 }
437 let base = std::path::Path::new(&config.working_dir);
438 let working_dir = match config.working_dir_isolation {
439 crate::config::WorkingDirIsolation::Direct => base.to_path_buf(),
440 crate::config::WorkingDirIsolation::Shared => base.join("shared"),
441 crate::config::WorkingDirIsolation::Chat => {
442 crate::internal::tool_runtime::runtime::chat_working_dir(
443 base,
444 &auth.caller_channel,
445 auth.caller_chat_id,
446 )
447 }
448 };
449 let runner = SubagentBashRunner { tools, auth };
450 let outcomes = crate::completion_contract::verify_criteria(
451 criteria,
452 final_text,
453 &working_dir,
454 Some(&runner),
455 )
456 .await;
457 let passed = outcomes.iter().filter(|o| o.passed).count();
458 log_subagent_event(
459 db,
460 run_id,
461 "contract",
462 Some(format!(
463 "{} {passed}/{}",
464 if passed == outcomes.len() {
465 "verified"
466 } else {
467 "failed"
468 },
469 outcomes.len()
470 )),
471 )
472 .await;
473 let report = crate::completion_contract::render_report(&outcomes);
474 Some(ContractVerdict {
475 annotated: format!("{report}\n{final_text}"),
476 passed: passed == outcomes.len(),
477 report,
478 })
479}
480
481fn contract_retry_prompt(report: &str) -> String {
484 format!(
485 "Completion contract verification FAILED:\n{report}\nThe failed criteria are unmet \
486 work — complete them now (use tools), then submit your result again via `submit_result`. \
487 This is your final attempt."
488 )
489}
490
491async fn run_sub_agent_task(
492 params: RunSubAgentTaskParams,
493) -> Result<(String, String, i64, i64), String> {
494 let RunSubAgentTaskParams {
495 config,
496 db,
497 channel_registry,
498 auth_context,
499 run_id,
500 runtime,
501 acp_target,
502 depth,
503 run_token_budget,
504 task,
505 context,
506 specialist,
507 exit_criteria,
508 local_cancel,
509 } = params;
510 if matches!(runtime, SubagentExecutionRuntime::Acp) {
511 let Some(acp_target) = acp_target else {
512 return Err("ACP runtime target was not resolved".into());
513 };
514 let mut context = context;
518 if !exit_criteria.is_empty() {
519 context.push_str(&crate::completion_contract::render_for_prompt(
520 &exit_criteria,
521 ));
522 }
523 let first = crate::acp_subagent::run_acp_subagent_task(
524 crate::acp_subagent::AcpSubagentTaskParams {
525 config: config.clone(),
526 db: db.clone(),
527 auth_context: auth_context.clone(),
528 run_id: run_id.clone(),
529 task: task.clone(),
530 context: context.clone(),
531 local_cancel: local_cancel.clone(),
532 target: acp_target.clone(),
533 },
534 )
535 .await?;
536 if exit_criteria.is_empty() {
537 return Ok(first);
538 }
539 let tools = ToolRegistry::new_sub_agent(&config, db.clone(), Some(channel_registry), false);
542 let (final_text, artifact_json, mut in_tok, mut out_tok) = first;
543 let verdict = apply_completion_contract(
544 &exit_criteria,
545 &final_text,
546 &config,
547 &tools,
548 &auth_context,
549 db.clone(),
550 &run_id,
551 )
552 .await
553 .expect("criteria checked non-empty");
554 if verdict.passed {
555 return Ok((verdict.annotated, artifact_json, in_tok, out_tok));
556 }
557 log_subagent_event(db.clone(), &run_id, "contract_retry", None).await;
558 let retry_context = format!("{context}\n\n{}", contract_retry_prompt(&verdict.report));
559 let (retry_text, retry_artifact, retry_in, retry_out) =
560 crate::acp_subagent::run_acp_subagent_task(
561 crate::acp_subagent::AcpSubagentTaskParams {
562 config: config.clone(),
563 db: db.clone(),
564 auth_context: auth_context.clone(),
565 run_id: run_id.clone(),
566 task,
567 context: retry_context,
568 local_cancel,
569 target: acp_target,
570 },
571 )
572 .await?;
573 in_tok += retry_in;
574 out_tok += retry_out;
575 let verdict = apply_completion_contract(
576 &exit_criteria,
577 &retry_text,
578 &config,
579 &tools,
580 &auth_context,
581 db.clone(),
582 &run_id,
583 )
584 .await
585 .expect("criteria checked non-empty");
586 return Ok((verdict.annotated, retry_artifact, in_tok, out_tok));
587 }
588
589 let llm = crate::llm::create_provider(&config);
590 let allow_session_tools = depth < config.subagents.max_spawn_depth as i64;
591 let tools = ToolRegistry::new_sub_agent(
592 &config,
593 db.clone(),
594 Some(channel_registry),
595 allow_session_tools,
596 );
597 let mut tool_defs = tools.definitions().to_vec();
601 tool_defs.push(submit_result_tool_definition());
602
603 let profile = crate::tools::specialists::resolve_specialist(Some(specialist.as_str()));
604 let system_prompt = format!(
605 "{persona}\n\nComplete the task thoroughly with tool use when needed.\nYou are a background worker: you have NO tools to message the chat, write memory, or schedule tasks (no `send_message`, `write_memory`, or `schedule` — do not look for them or stall when they are missing). `read_memory` and `structured_memory_search` are read-only lookups. Any durable facts you surface in `findings`/`final_answer` are persisted by the orchestrator automatically — just report them, don't try to save them yourself.\nFor long tasks, call `report_progress` at meaningful milestones with a one-line status so the user gets colleague-style updates while you work.\nIf a sub-problem falls outside your expertise, get a quick second opinion from the right specialist with `consult_specialist` (e.g. as a researcher, hand a draft to the writer; as a coder, ask the mathematician to check a formula) and weave their answer into your work — don't fake expertise you don't have. If a sub-problem is large enough to need its own run and you're allowed to spawn, delegate it with `sessions_spawn`; otherwise name the right specialist in next_actions.\nOutput contract (required): when the task is complete, call the `submit_result` tool exactly once with your structured result (summary, findings, artifacts, next_actions, final_answer). Prefer `submit_result` over writing the result as text. If you do answer in text instead, it MUST be a single JSON object with those same keys and nothing else.",
606 persona = profile.persona
607 );
608
609 let mut user_content = if context.is_empty() {
610 task.to_string()
611 } else {
612 format!("Context: {context}\n\nTask: {task}")
613 };
614 if !exit_criteria.is_empty() {
615 user_content.push_str(&crate::completion_contract::render_for_prompt(
616 &exit_criteria,
617 ));
618 }
619
620 let mut messages = vec![Message {
621 role: "user".into(),
622 content: MessageContent::Text(user_content),
623 }];
624 let mut input_tokens_sum = 0_i64;
625 let mut output_tokens_sum = 0_i64;
626 let mut repair_attempted = false;
630 let mut contract_retry_attempted = false;
634
635 for _ in 0..MAX_SUB_AGENT_ITERATIONS {
636 if is_cancelled(db.clone(), &run_id, &local_cancel).await? {
637 return Err("cancelled".into());
638 }
639
640 let response = llm
641 .send_message(&system_prompt, messages.clone(), Some(tool_defs.clone()))
642 .await
643 .map_err(|e| format!("Sub-agent API error: {e}"))?;
644
645 if let Some(usage) = &response.usage {
646 let input_tokens = i64::from(usage.input_tokens);
647 let output_tokens = i64::from(usage.output_tokens);
648 input_tokens_sum += input_tokens;
649 output_tokens_sum += output_tokens;
650
651 let channel = auth_context.caller_channel.clone();
652 let provider = config.llm_provider.clone();
653 let model = config.model.clone();
654 let chat_id = auth_context.caller_chat_id;
655 let _ = call_blocking(db.clone(), move |db| {
656 db.log_llm_usage(
657 chat_id,
658 &channel,
659 &provider,
660 &model,
661 input_tokens,
662 output_tokens,
663 "subagent_run",
664 )
665 .map(|_| ())
666 })
667 .await;
668 let total = input_tokens_sum + output_tokens_sum;
669 if run_token_budget > 0 && total > run_token_budget {
670 return Err(format!(
671 "budget_exceeded: total_tokens={} budget={}",
672 total, run_token_budget
673 ));
674 }
675 }
676
677 let stop_reason = response.stop_reason.as_deref().unwrap_or("end_turn");
678 if stop_reason == "end_turn" || stop_reason == "max_tokens" {
679 let text = response
680 .content
681 .iter()
682 .filter_map(|block| match block {
683 ResponseContentBlock::Text { text } => Some(text.as_str()),
684 _ => None,
685 })
686 .collect::<Vec<_>>()
687 .join("");
688 if stop_reason == "end_turn"
694 && !repair_attempted
695 && !text.trim().is_empty()
696 && !subagent_output_is_structured(&text)
697 {
698 repair_attempted = true;
699 log_subagent_event(db.clone(), &run_id, "output_repair", None).await;
700 messages.push(Message {
701 role: "assistant".into(),
702 content: MessageContent::Text(text),
703 });
704 messages.push(Message {
705 role: "user".into(),
706 content: MessageContent::Text(
707 "Your previous reply was not the required output. Respond with ONLY a single JSON object matching the contract: {\"summary\":string,\"findings\":string[],\"artifacts\":[],\"next_actions\":string[],\"final_answer\":string}. No prose, no markdown fences, no <think> tags."
708 .into(),
709 ),
710 });
711 continue;
712 }
713 let source = if text.is_empty() {
714 "(sub-agent produced no output)".to_string()
715 } else {
716 text
717 };
718 let (final_text, artifact_json) = normalize_subagent_artifact_payload(&source);
719 match apply_completion_contract(
720 &exit_criteria,
721 &final_text,
722 &config,
723 &tools,
724 &auth_context,
725 db.clone(),
726 &run_id,
727 )
728 .await
729 {
730 Some(verdict) if !verdict.passed && !contract_retry_attempted => {
731 contract_retry_attempted = true;
732 log_subagent_event(db.clone(), &run_id, "contract_retry", None).await;
733 messages.push(Message {
734 role: "assistant".into(),
735 content: MessageContent::Text(verdict.annotated),
736 });
737 messages.push(Message {
738 role: "user".into(),
739 content: MessageContent::Text(contract_retry_prompt(&verdict.report)),
740 });
741 continue;
742 }
743 Some(verdict) => {
744 return Ok((
745 verdict.annotated,
746 artifact_json,
747 input_tokens_sum,
748 output_tokens_sum,
749 ));
750 }
751 None => {
752 return Ok((
753 final_text,
754 artifact_json,
755 input_tokens_sum,
756 output_tokens_sum,
757 ));
758 }
759 }
760 }
761
762 if stop_reason == "tool_use" {
763 if let Some(submit_input) = response.content.iter().find_map(|b| match b {
766 ResponseContentBlock::ToolUse { name, input, .. } if name == SUBMIT_RESULT_TOOL => {
767 Some(input.clone())
768 }
769 _ => None,
770 }) {
771 log_subagent_event(db.clone(), &run_id, "submit_result", None).await;
772 let (final_text, artifact_json) =
773 normalize_subagent_artifact_payload(&submit_input.to_string());
774 match apply_completion_contract(
775 &exit_criteria,
776 &final_text,
777 &config,
778 &tools,
779 &auth_context,
780 db.clone(),
781 &run_id,
782 )
783 .await
784 {
785 Some(verdict) if !verdict.passed && !contract_retry_attempted => {
786 contract_retry_attempted = true;
787 log_subagent_event(db.clone(), &run_id, "contract_retry", None).await;
788 let submit_id = response
793 .content
794 .iter()
795 .find_map(|b| match b {
796 ResponseContentBlock::ToolUse { id, name, .. }
797 if name == SUBMIT_RESULT_TOOL =>
798 {
799 Some(id.clone())
800 }
801 _ => None,
802 })
803 .unwrap_or_default();
804 let assistant_content: Vec<ContentBlock> = response
805 .content
806 .iter()
807 .filter_map(|block| match block {
808 ResponseContentBlock::Text { text } => {
809 Some(ContentBlock::Text { text: text.clone() })
810 }
811 ResponseContentBlock::ToolUse {
812 id,
813 name,
814 input,
815 thought_signature,
816 } => Some(ContentBlock::ToolUse {
817 id: id.clone(),
818 name: name.clone(),
819 input: input.clone(),
820 thought_signature: thought_signature.clone(),
821 }),
822 ResponseContentBlock::Other => None,
823 })
824 .collect();
825 messages.push(Message {
826 role: "assistant".into(),
827 content: MessageContent::Blocks(assistant_content),
828 });
829 messages.push(Message {
830 role: "user".into(),
831 content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
832 tool_use_id: submit_id,
833 content: contract_retry_prompt(&verdict.report),
834 is_error: Some(true),
835 }]),
836 });
837 continue;
838 }
839 Some(verdict) => {
840 return Ok((
841 verdict.annotated,
842 artifact_json,
843 input_tokens_sum,
844 output_tokens_sum,
845 ));
846 }
847 None => {
848 return Ok((
849 final_text,
850 artifact_json,
851 input_tokens_sum,
852 output_tokens_sum,
853 ));
854 }
855 }
856 }
857
858 let assistant_content: Vec<ContentBlock> = response
859 .content
860 .iter()
861 .filter_map(|block| match block {
862 ResponseContentBlock::Text { text } => {
863 Some(ContentBlock::Text { text: text.clone() })
864 }
865 ResponseContentBlock::ToolUse {
866 id,
867 name,
868 input,
869 thought_signature,
870 } => Some(ContentBlock::ToolUse {
871 id: id.clone(),
872 name: name.clone(),
873 input: input.clone(),
874 thought_signature: thought_signature.clone(),
875 }),
876 ResponseContentBlock::Other => None,
877 })
878 .collect();
879
880 messages.push(Message {
881 role: "assistant".into(),
882 content: MessageContent::Blocks(assistant_content),
883 });
884
885 let mut tool_results = Vec::new();
886 for block in &response.content {
887 if let ResponseContentBlock::ToolUse {
888 id, name, input, ..
889 } = block
890 {
891 log_subagent_event(
892 db.clone(),
893 &run_id,
894 "tool_use",
895 Some(format!("tool={name}")),
896 )
897 .await;
898 let mut tool_input = input.clone();
899 if let Some(obj) = tool_input.as_object_mut() {
900 let remaining_budget = if run_token_budget > 0 {
901 Some((run_token_budget - input_tokens_sum - output_tokens_sum).max(0))
902 } else {
903 None
904 };
905 obj.insert(
906 "__subagent_runtime".to_string(),
907 json!({
908 "run_id": run_id.clone(),
909 "depth": depth,
910 "runtime": "native",
911 "runtime_target": serde_json::Value::Null,
912 "token_budget_remaining": remaining_budget,
913 }),
914 );
915 }
916 let result = tools
917 .execute_with_auth(name, tool_input, &auth_context)
918 .await;
919 tool_results.push(ContentBlock::ToolResult {
920 tool_use_id: id.clone(),
921 content: result.content,
922 is_error: if result.is_error { Some(true) } else { None },
923 });
924 }
925 }
926
927 messages.push(Message {
928 role: "user".into(),
929 content: MessageContent::Blocks(tool_results),
930 });
931 continue;
932 }
933
934 let text = response
935 .content
936 .iter()
937 .filter_map(|block| match block {
938 ResponseContentBlock::Text { text } => Some(text.as_str()),
939 _ => None,
940 })
941 .collect::<Vec<_>>()
942 .join("");
943 let source = if text.is_empty() {
944 "(sub-agent produced no output)".to_string()
945 } else {
946 text
947 };
948 let (final_text, artifact_json) = normalize_subagent_artifact_payload(&source);
949 match apply_completion_contract(
950 &exit_criteria,
951 &final_text,
952 &config,
953 &tools,
954 &auth_context,
955 db.clone(),
956 &run_id,
957 )
958 .await
959 {
960 Some(verdict) if !verdict.passed && !contract_retry_attempted => {
961 contract_retry_attempted = true;
962 log_subagent_event(db.clone(), &run_id, "contract_retry", None).await;
963 messages.push(Message {
964 role: "assistant".into(),
965 content: MessageContent::Text(verdict.annotated),
966 });
967 messages.push(Message {
968 role: "user".into(),
969 content: MessageContent::Text(contract_retry_prompt(&verdict.report)),
970 });
971 continue;
972 }
973 Some(verdict) => {
974 return Ok((
975 verdict.annotated,
976 artifact_json,
977 input_tokens_sum,
978 output_tokens_sum,
979 ));
980 }
981 None => {
982 return Ok((
983 final_text,
984 artifact_json,
985 input_tokens_sum,
986 output_tokens_sum,
987 ));
988 }
989 }
990 }
991
992 Err("Sub-agent reached maximum iterations without completing the task.".into())
993}
994
995async fn maybe_post_fan_in_summary(
1000 config: &Config,
1001 channel_registry: Arc<ChannelRegistry>,
1002 db: Arc<Database>,
1003 chat_id: i64,
1004 parent_id: &str,
1005) {
1006 let parent_owned = parent_id.to_string();
1007 let active = call_blocking(db.clone(), {
1008 let p = parent_owned.clone();
1009 move |db| db.count_active_subagent_children(&p)
1010 })
1011 .await
1012 .unwrap_or(0);
1013 if active > 0 {
1014 return;
1015 }
1016 let children = match call_blocking(db.clone(), {
1017 let p = parent_owned.clone();
1018 move |db| db.list_subagent_children(&p)
1019 })
1020 .await
1021 {
1022 Ok(v) => v,
1023 Err(e) => {
1024 warn!("fan-in: failed to list children of {parent_id}: {e}");
1025 return;
1026 }
1027 };
1028 if children.len() < 2 {
1031 return;
1032 }
1033
1034 let n = children.len();
1035 let mut lines = vec![format!("🧩 All {n} sub-tasks done:")];
1036 let mut caller_channel = String::new();
1037 for c in &children {
1038 if caller_channel.is_empty() {
1039 caller_channel = c.caller_channel.clone();
1040 }
1041 let emoji = match c.status.as_str() {
1042 "completed" => "✅",
1043 "cancelled" => "🛑",
1044 "timed_out" => "⏱️",
1045 _ => "❌",
1046 };
1047 let name = c
1048 .label
1049 .clone()
1050 .filter(|l| !l.trim().is_empty())
1051 .unwrap_or_else(|| c.task.chars().take(40).collect::<String>());
1052 let detail = c
1053 .result_text
1054 .clone()
1055 .or_else(|| c.error_text.clone())
1056 .map(|t| {
1057 let snippet: String = t.trim().chars().take(120).collect();
1058 format!(" — {snippet}")
1059 })
1060 .unwrap_or_default();
1061 lines.push(format!("{emoji} {name}{detail}"));
1062 }
1063 let summary = lines.join("\n");
1064
1065 let announce_id = format!("{parent_owned}:fanin");
1066 let enqueue = call_blocking(db.clone(), {
1067 let channel = caller_channel.clone();
1068 let summary = summary.clone();
1069 move |db| db.enqueue_subagent_announce(&announce_id, chat_id, &channel, &summary)
1070 })
1071 .await;
1072 if enqueue.is_ok() {
1074 let _ = flush_pending_announces_once(config, channel_registry, db, 10).await;
1075 }
1076}
1077
1078async fn build_announce_payload(
1079 db: Arc<Database>,
1080 chat_id: i64,
1081 run_id: &str,
1082) -> Result<String, String> {
1083 let run_id_owned = run_id.to_string();
1084 let run = match call_blocking(db.clone(), move |db| {
1085 db.get_subagent_run(&run_id_owned, chat_id)
1086 })
1087 .await
1088 {
1089 Ok(Some(run)) => run,
1090 Ok(None) => return Err("run_not_found".into()),
1091 Err(e) => return Err(format!("failed_loading_run: {e}")),
1092 };
1093
1094 let status_emoji = match run.status.as_str() {
1095 "completed" => "✅",
1096 "cancelled" => "🛑",
1097 "timed_out" => "⏱️",
1098 _ => "❌",
1099 };
1100
1101 let mut text = format!(
1102 "{status_emoji} Subagent `{}` finished\nstatus: {}\ninput_tokens: {}\noutput_tokens: {}\nbudget: {}",
1103 run.run_id, run.status, run.input_tokens, run.output_tokens, run.token_budget
1104 );
1105 if let Some(err) = &run.error_text {
1106 text.push_str(&format!("\nerror: {err}"));
1107 }
1108 if let Some(result) = &run.result_text {
1109 let visible = crate::agent_engine::sanitize_user_visible_text(result);
1110 let clipped: String = visible.chars().take(1200).collect();
1111 text.push_str("\nresult:\n");
1112 text.push_str(&clipped);
1113 }
1114 Ok(text)
1115}
1116
1117pub async fn flush_pending_announces_once(
1118 config: &Config,
1119 channel_registry: Arc<ChannelRegistry>,
1120 db: Arc<Database>,
1121 max_batch: usize,
1122) -> usize {
1123 let now = chrono::Utc::now().to_rfc3339();
1124 let rows = match call_blocking(db.clone(), move |db| {
1125 db.list_due_subagent_announces(&now, max_batch)
1126 })
1127 .await
1128 {
1129 Ok(v) => v,
1130 Err(e) => {
1131 warn!("failed to list due subagent announces: {e}");
1132 return 0;
1133 }
1134 };
1135
1136 let mut processed = 0usize;
1137 for row in rows {
1138 let bot_username = config.bot_username_for_channel(&row.caller_channel);
1139 let delivery = deliver_and_store_bot_message(
1140 channel_registry.as_ref(),
1141 db.clone(),
1142 &bot_username,
1143 row.chat_id,
1144 &row.payload_text,
1145 )
1146 .await;
1147 match delivery {
1148 Ok(_) => {
1149 let id = row.id;
1150 let _ =
1151 call_blocking(db.clone(), move |db| db.mark_subagent_announce_sent(id)).await;
1152 }
1153 Err(err) => {
1154 let next_attempts = row.attempts + 1;
1155 let terminal = next_attempts >= 5;
1156 let delay_secs = 1_i64 << next_attempts.min(6);
1157 let next_at = if terminal {
1158 None
1159 } else {
1160 Some((chrono::Utc::now() + chrono::Duration::seconds(delay_secs)).to_rfc3339())
1161 };
1162 let id = row.id;
1163 let err_text = err;
1164 let _ = call_blocking(db.clone(), move |db| {
1165 db.mark_subagent_announce_retry(
1166 id,
1167 next_attempts,
1168 next_at.as_deref(),
1169 &err_text,
1170 terminal,
1171 )
1172 })
1173 .await;
1174 }
1175 }
1176 processed += 1;
1177 }
1178 processed
1179}
1180
1181pub struct SessionsSpawnTool {
1182 config: Config,
1183 db: Arc<Database>,
1184 channel_registry: Arc<ChannelRegistry>,
1185}
1186
1187impl SessionsSpawnTool {
1188 pub fn new(config: &Config, db: Arc<Database>, channel_registry: Arc<ChannelRegistry>) -> Self {
1189 Self {
1190 config: config.clone(),
1191 db,
1192 channel_registry,
1193 }
1194 }
1195}
1196
1197#[async_trait]
1198impl Tool for SessionsSpawnTool {
1199 fn name(&self) -> &str {
1200 "sessions_spawn"
1201 }
1202
1203 fn definition(&self) -> ToolDefinition {
1204 ToolDefinition {
1205 name: "sessions_spawn".into(),
1206 description: format!(
1207 "Spawn an asynchronous sub-agent run for long tasks. Returns immediately with a run id. Use subagents_list/subagents_info/subagents_kill to manage runs. Pick a `specialist` to route the work to a focused expert. Available specialists: {}.",
1208 crate::tools::specialists::specialist_catalog()
1209 ),
1210 input_schema: schema_object(
1211 json!({
1212 "task": {
1213 "type": "string",
1214 "description": "Task for the spawned sub-agent"
1215 },
1216 "context": {
1217 "type": "string",
1218 "description": "Optional extra context passed to the sub-agent"
1219 },
1220 "specialist": {
1221 "type": "string",
1222 "enum": crate::tools::specialists::specialist_names(),
1223 "description": "Which specialist persona the sub-agent adopts. Defaults to generalist."
1224 },
1225 "label": {
1226 "type": "string",
1227 "description": "Short human-friendly name for this task (e.g. 'competitor research'), shown when listing/reporting progress. Recommended when running several tasks at once."
1228 },
1229 "runtime": {
1230 "type": "string",
1231 "enum": ["native", "acp"],
1232 "description": "Execution backend for the sub-agent. Defaults to native."
1233 },
1234 "runtime_target": {
1235 "type": "string",
1236 "description": "Optional named ACP target when runtime is 'acp'."
1237 },
1238 "chat_id": {
1239 "type": "integer",
1240 "description": "Target chat id. Defaults to current chat."
1241 },
1242 "token_budget": {
1243 "type": "integer",
1244 "description": "Optional token budget cap for this run."
1245 },
1246 "exit_criteria": {
1247 "type": "array",
1248 "maxItems": 8,
1249 "description": "Optional completion contract: machine-checkable exit criteria the runtime VERIFIES when the run finishes (the result is marked VERIFIED/FAILED with evidence). Entries: {type:'file_exists', path} | {type:'file_contains', path, needle} | {type:'file_min_bytes', path, min_bytes} | {type:'result_contains', needle} | {type:'command', run, expect_contains?}. Paths are relative to the chat working dir; commands run through the sandboxed bash tool. Declare criteria whenever the task has an objectively checkable outcome.",
1250 "items": {"type": "object"}
1251 }
1252 }),
1253 &["task"],
1254 ),
1255 }
1256 }
1257
1258 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1259 let auth =
1260 match auth_context_from_input(&input) {
1261 Some(v) => v,
1262 None => return ToolResult::error(
1263 "sessions_spawn requires caller auth context; run from an active chat session"
1264 .into(),
1265 ),
1266 };
1267
1268 let chat_id = input
1269 .get("chat_id")
1270 .and_then(|v| v.as_i64())
1271 .unwrap_or(auth.caller_chat_id);
1272 if let Err(e) = authorize_chat_access(&input, chat_id) {
1273 return ToolResult::error(e);
1274 }
1275
1276 let task = match input.get("task").and_then(|v| v.as_str()) {
1277 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
1278 _ => return ToolResult::error("Missing required parameter: task".into()),
1279 };
1280 let context = input
1281 .get("context")
1282 .and_then(|v| v.as_str())
1283 .unwrap_or("")
1284 .trim()
1285 .to_string();
1286 let specialist = crate::tools::specialists::resolve_specialist(
1288 input.get("specialist").and_then(|v| v.as_str()),
1289 )
1290 .name
1291 .to_string();
1292 let exit_criteria =
1293 match crate::completion_contract::parse_exit_criteria(input.get("exit_criteria")) {
1294 Ok(v) => v,
1295 Err(e) => return ToolResult::error(e),
1296 };
1297 let label = input
1299 .get("label")
1300 .and_then(|v| v.as_str())
1301 .map(str::trim)
1302 .filter(|v| !v.is_empty())
1303 .map(|v| v.chars().take(80).collect::<String>());
1304 let parent_meta = subagent_runtime_meta_from_input(&input);
1305 let execution_runtime =
1306 match SubagentExecutionRuntime::from_input(&input, parent_meta.as_ref()) {
1307 Ok(v) => v,
1308 Err(e) => return ToolResult::error(e),
1309 };
1310 let runtime_target = input
1311 .get("runtime_target")
1312 .and_then(|v| v.as_str())
1313 .map(str::trim)
1314 .filter(|v| !v.is_empty())
1315 .map(ToOwned::to_owned)
1316 .or_else(|| {
1317 parent_meta
1318 .as_ref()
1319 .and_then(|meta| meta.runtime_target.clone())
1320 });
1321 let parent_depth = parent_meta.as_ref().map(|m| m.depth).unwrap_or(0);
1322 let parent_budget_remaining = parent_meta.as_ref().and_then(|m| m.token_budget_remaining);
1323 let child_depth = if parent_depth > 0 {
1324 parent_depth + 1
1325 } else {
1326 1
1327 };
1328 if child_depth as usize > self.config.subagents.max_spawn_depth {
1329 return ToolResult::error(format!(
1330 "subagent spawn depth exceeded: requested depth {}, max {}",
1331 child_depth, self.config.subagents.max_spawn_depth
1332 ));
1333 }
1334 let parent_run_id = input
1335 .get("__subagent_runtime")
1336 .and_then(|v| v.get("run_id"))
1337 .and_then(|v| v.as_str())
1338 .map(str::to_string);
1339 let requested_budget = input
1340 .get("token_budget")
1341 .and_then(|v| v.as_i64())
1342 .filter(|v| *v > 0);
1343 let child_token_budget = match compute_child_token_budget(
1344 requested_budget,
1345 parent_budget_remaining,
1346 self.config.subagents.max_tokens_per_run,
1347 ) {
1348 Ok(v) => v,
1349 Err(e) => return ToolResult::error(e),
1350 };
1351
1352 let db_for_count = self.db.clone();
1353 let active_count = match call_blocking(db_for_count, move |db| {
1354 db.count_active_subagent_runs_for_chat(chat_id)
1355 })
1356 .await
1357 {
1358 Ok(v) => v,
1359 Err(e) => {
1360 return ToolResult::error(format!("Failed checking active subagent runs: {e}"));
1361 }
1362 };
1363 if active_count as usize >= self.config.subagents.max_active_per_chat {
1364 return ToolResult::error(format!(
1365 "Too many active subagent runs for this chat (limit: {})",
1366 self.config.subagents.max_active_per_chat
1367 ));
1368 }
1369 if let Some(parent_id) = parent_run_id.as_ref() {
1370 let parent_id_for_count = parent_id.clone();
1371 let active_children = match call_blocking(self.db.clone(), move |db| {
1372 db.count_active_subagent_children(&parent_id_for_count)
1373 })
1374 .await
1375 {
1376 Ok(v) => v,
1377 Err(e) => {
1378 return ToolResult::error(format!(
1379 "Failed checking active subagent child runs: {e}"
1380 ));
1381 }
1382 };
1383 if active_children as usize >= self.config.subagents.max_children_per_run {
1384 return ToolResult::error(format!(
1385 "Too many active child runs for this parent (limit: {})",
1386 self.config.subagents.max_children_per_run
1387 ));
1388 }
1389 }
1390
1391 let run_id = format!("subrun-{}", uuid::Uuid::new_v4());
1392 let (provider, model, acp_target) = match execution_runtime {
1393 SubagentExecutionRuntime::Native => (
1394 self.config.llm_provider.clone(),
1395 self.config.model.clone(),
1396 None,
1397 ),
1398 SubagentExecutionRuntime::Acp => {
1399 if !self.config.subagents.acp.default_target.enabled {
1400 return ToolResult::error(
1401 "ACP runtime is disabled. Set `subagents.acp.enabled: true` and configure `subagents.acp.command` or `subagents.acp.targets` first."
1402 .into(),
1403 );
1404 }
1405 let resolved = match self
1406 .config
1407 .subagents
1408 .acp
1409 .resolve_target(runtime_target.as_deref())
1410 {
1411 Ok(target) => target,
1412 Err(err) => return ToolResult::error(err),
1413 };
1414 (
1415 crate::acp_subagent::acp_runtime_provider(resolved.name.as_deref()),
1416 crate::acp_subagent::acp_runtime_model(&resolved),
1417 Some(resolved),
1418 )
1419 }
1420 };
1421
1422 let run_id_for_insert = run_id.clone();
1423 let task_for_insert = task.clone();
1424 let context_for_insert = context.clone();
1425 let caller_channel_for_insert = auth.caller_channel.clone();
1426 let parent_for_insert = parent_run_id.clone();
1427 let label_for_insert = label.clone();
1428 if let Err(e) = call_blocking(self.db.clone(), move |db| {
1429 db.create_subagent_run(CreateSubagentRunParams {
1430 run_id: &run_id_for_insert,
1431 parent_run_id: parent_for_insert.as_deref(),
1432 depth: child_depth,
1433 token_budget: child_token_budget,
1434 chat_id,
1435 caller_channel: &caller_channel_for_insert,
1436 task: &task_for_insert,
1437 context: &context_for_insert,
1438 provider: &provider,
1439 model: &model,
1440 label: label_for_insert.as_deref(),
1441 })
1442 })
1443 .await
1444 {
1445 return ToolResult::error(format!("Failed creating subagent run: {e}"));
1446 }
1447 log_subagent_event(
1448 self.db.clone(),
1449 &run_id,
1450 "accepted",
1451 Some(format!(
1452 "depth={child_depth} runtime={} specialist={specialist}{}",
1453 execution_runtime.as_str(),
1454 runtime_target
1455 .as_deref()
1456 .map(|target| format!(" runtime_target={target}"))
1457 .unwrap_or_default()
1458 )),
1459 )
1460 .await;
1461
1462 let runtime = subagent_runtime(&self.config);
1463 let local_cancel = runtime.register_run(&run_id);
1464 let db = self.db.clone();
1465 let cfg = self.config.clone();
1466 let run_id_async = run_id.clone();
1467 let task_async = task.clone();
1468 let context_async = context.clone();
1469 let specialist_async = specialist.clone();
1470 let exit_criteria_async = exit_criteria.clone();
1471 let parent_run_id_async = parent_run_id.clone();
1472 let auth_async = ToolAuthContext {
1473 caller_channel: auth.caller_channel.clone(),
1474 caller_chat_id: chat_id,
1475 principal: format!("subagent:{run_id}"),
1476 control_chat_ids: auth.control_chat_ids.clone(),
1477 env_files: auth.env_files.clone(),
1478 };
1479 let channel_registry = self.channel_registry.clone();
1480 let subagent_channel_registry = self.channel_registry.clone();
1481 let fan_in_channel_registry = self.channel_registry.clone();
1482 let caller_channel_for_event = auth.caller_channel.clone();
1483 TASK_RUNTIME.spawn(async move {
1484 let run_id_for_finish = run_id_async.clone();
1485 let _ = call_blocking(db.clone(), {
1486 let run_id = run_id_async.clone();
1487 move |db| db.mark_subagent_queued(&run_id)
1488 })
1489 .await;
1490 log_subagent_event(db.clone(), &run_id_async, "queued", None).await;
1491
1492 let _permit = match runtime.semaphore.acquire().await {
1493 Ok(p) => p,
1494 Err(_) => {
1495 let _ = call_blocking(db.clone(), move |db| {
1496 db.mark_subagent_finished(FinishSubagentRunParams {
1497 run_id: &run_id_for_finish,
1498 status: "failed",
1499 error_text: Some("subagent runtime is shutting down"),
1500 result_text: None,
1501 artifact_json: None,
1502 input_tokens: 0,
1503 output_tokens: 0,
1504 })
1505 })
1506 .await;
1507 runtime.remove_run(&run_id_async);
1508 return;
1509 }
1510 };
1511
1512 let _ = call_blocking(db.clone(), {
1513 let run_id = run_id_async.clone();
1514 move |db| db.mark_subagent_running(&run_id)
1515 })
1516 .await;
1517 log_subagent_event(db.clone(), &run_id_async, "running", None).await;
1518
1519 let timeout_secs = cfg.subagents.run_timeout_secs;
1520 let run_future = run_sub_agent_task(RunSubAgentTaskParams {
1521 config: cfg.clone(),
1522 db: db.clone(),
1523 channel_registry: subagent_channel_registry,
1524 auth_context: auth_async,
1525 run_id: run_id_async.clone(),
1526 runtime: execution_runtime,
1527 acp_target,
1528 depth: child_depth,
1529 run_token_budget: child_token_budget,
1530 task: task_async,
1531 context: context_async,
1532 specialist: specialist_async,
1533 exit_criteria: exit_criteria_async,
1534 local_cancel,
1535 });
1536
1537 let final_outcome = if timeout_secs > 0 {
1538 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), run_future)
1539 .await
1540 {
1541 Ok(result) => result,
1542 Err(_) => Err("timed_out".to_string()),
1543 }
1544 } else {
1545 run_future.await
1546 };
1547
1548 let finish_status = match &final_outcome {
1549 Ok(_) => "completed",
1550 Err(err) if err == "cancelled" => "cancelled",
1551 Err(err) if err == "timed_out" => "timed_out",
1552 Err(err) if err.contains("budget_exceeded:") => "budget_exceeded",
1553 Err(_) => "failed",
1554 };
1555 crate::agent_engine::send_turn_event(
1558 &auth.caller_channel,
1559 chat_id,
1560 crate::agent_engine::AgentEvent::SubagentFinished {
1561 run_id: run_id_async.clone(),
1562 status: finish_status.to_string(),
1563 },
1564 );
1565
1566 match final_outcome {
1567 Ok((result, artifact_json, input_tokens, output_tokens)) => {
1568 let rid = run_id_for_finish.clone();
1569 let _ = call_blocking(db.clone(), move |db| {
1570 db.mark_subagent_finished(FinishSubagentRunParams {
1571 run_id: &rid,
1572 status: "completed",
1573 error_text: None,
1574 result_text: Some(&result),
1575 artifact_json: Some(&artifact_json),
1576 input_tokens,
1577 output_tokens,
1578 })
1579 })
1580 .await;
1581 log_subagent_event(db.clone(), &run_id_for_finish, "completed", None).await;
1582 }
1583 Err(err) if err == "cancelled" => {
1584 let rid = run_id_for_finish.clone();
1585 let _ = call_blocking(db.clone(), move |db| {
1586 db.mark_subagent_finished(FinishSubagentRunParams {
1587 run_id: &rid,
1588 status: "cancelled",
1589 error_text: Some("Cancelled by user"),
1590 result_text: None,
1591 artifact_json: None,
1592 input_tokens: 0,
1593 output_tokens: 0,
1594 })
1595 })
1596 .await;
1597 log_subagent_event(db.clone(), &run_id_for_finish, "cancelled", None).await;
1598 }
1599 Err(err) if err == "timed_out" => {
1600 let rid = run_id_for_finish.clone();
1601 let _ = call_blocking(db.clone(), move |db| {
1602 db.mark_subagent_finished(FinishSubagentRunParams {
1603 run_id: &rid,
1604 status: "timed_out",
1605 error_text: Some("Sub-agent run exceeded configured timeout"),
1606 result_text: None,
1607 artifact_json: None,
1608 input_tokens: 0,
1609 output_tokens: 0,
1610 })
1611 })
1612 .await;
1613 log_subagent_event(db.clone(), &run_id_for_finish, "timed_out", None).await;
1614 }
1615 Err(err) => {
1616 let rid = run_id_for_finish.clone();
1617 let err_for_db = err.clone();
1618 let status = if err_for_db.contains("budget_exceeded:") {
1619 "budget_exceeded"
1620 } else {
1621 "failed"
1622 };
1623 let _ = call_blocking(db.clone(), move |db| {
1624 db.mark_subagent_finished(FinishSubagentRunParams {
1625 run_id: &rid,
1626 status,
1627 error_text: Some(&err_for_db),
1628 result_text: None,
1629 artifact_json: None,
1630 input_tokens: 0,
1631 output_tokens: 0,
1632 })
1633 })
1634 .await;
1635 log_subagent_event(db.clone(), &run_id_for_finish, "failed", Some(err)).await;
1636 }
1637 }
1638
1639 runtime.remove_run(&run_id_async);
1640
1641 let user_chat_announces_enabled = cfg.subagents.announce_to_chat
1642 && auth.caller_channel != "weixin"
1643 && !auth.caller_channel.starts_with("weixin.");
1644 if user_chat_announces_enabled {
1645 match build_announce_payload(db.clone(), chat_id, &run_id_async).await {
1646 Ok(payload) => {
1647 let rid = run_id_async.clone();
1648 let caller_channel = auth.caller_channel.clone();
1649 let _ = call_blocking(db.clone(), move |db| {
1650 db.enqueue_subagent_announce(&rid, chat_id, &caller_channel, &payload)
1651 })
1652 .await;
1653 let _ =
1654 flush_pending_announces_once(&cfg, channel_registry, db.clone(), 10)
1655 .await;
1656 }
1657 Err(e) => {
1658 warn!("failed to build announce payload for run {run_id_async}: {e}");
1659 }
1660 }
1661 }
1662
1663 if user_chat_announces_enabled && cfg.subagents.fan_in_summary {
1666 if let Some(parent_id) = parent_run_id_async.as_ref() {
1667 maybe_post_fan_in_summary(
1668 &cfg,
1669 fan_in_channel_registry,
1670 db,
1671 chat_id,
1672 parent_id,
1673 )
1674 .await;
1675 }
1676 }
1677 });
1678
1679 info!("subagent accepted run_id={run_id} chat_id={chat_id}");
1680 crate::agent_engine::send_turn_event(
1681 &caller_channel_for_event,
1682 chat_id,
1683 crate::agent_engine::AgentEvent::SubagentStarted {
1684 run_id: run_id.clone(),
1685 label: label.clone().unwrap_or_else(|| specialist.clone()),
1686 },
1687 );
1688 ToolResult::success(
1689 json!({
1690 "status": "accepted",
1691 "run_id": run_id,
1692 "chat_id": chat_id,
1693 "depth": child_depth,
1694 "specialist": specialist,
1695 "label": label,
1696 "runtime": execution_runtime.as_str(),
1697 "runtime_target": runtime_target,
1698 "token_budget": child_token_budget,
1699 "parent_run_id": parent_run_id,
1700 })
1701 .to_string(),
1702 )
1703 }
1704}
1705
1706pub struct SubagentsListTool {
1707 db: Arc<Database>,
1708}
1709
1710impl SubagentsListTool {
1711 pub fn new(db: Arc<Database>) -> Self {
1712 Self { db }
1713 }
1714}
1715
1716#[async_trait]
1717impl Tool for SubagentsListTool {
1718 fn name(&self) -> &str {
1719 "subagents_list"
1720 }
1721
1722 fn definition(&self) -> ToolDefinition {
1723 ToolDefinition {
1724 name: "subagents_list".into(),
1725 description: "List recent subagent runs for the current chat.".into(),
1726 input_schema: schema_object(
1727 json!({
1728 "chat_id": {"type": "integer"},
1729 "limit": {"type": "integer", "minimum": 1, "maximum": 100}
1730 }),
1731 &[],
1732 ),
1733 }
1734 }
1735
1736 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1737 let auth =
1738 match auth_context_from_input(&input) {
1739 Some(v) => v,
1740 None => return ToolResult::error(
1741 "subagents_list requires caller auth context; run from an active chat session"
1742 .into(),
1743 ),
1744 };
1745 let chat_id = input
1746 .get("chat_id")
1747 .and_then(|v| v.as_i64())
1748 .unwrap_or(auth.caller_chat_id);
1749 if let Err(e) = authorize_chat_access(&input, chat_id) {
1750 return ToolResult::error(e);
1751 }
1752 let limit = input
1753 .get("limit")
1754 .and_then(|v| v.as_u64())
1755 .unwrap_or(20)
1756 .clamp(1, 100) as usize;
1757
1758 let rows = match call_blocking(self.db.clone(), move |db| {
1759 db.list_subagent_runs(chat_id, limit)
1760 })
1761 .await
1762 {
1763 Ok(v) => v,
1764 Err(e) => return ToolResult::error(format!("Failed listing subagent runs: {e}")),
1765 };
1766 let payload: Vec<serde_json::Value> = rows
1767 .into_iter()
1768 .map(|r| {
1769 json!({
1770 "run_id": r.run_id,
1771 "label": r.label,
1772 "parent_run_id": r.parent_run_id,
1773 "depth": r.depth,
1774 "token_budget": r.token_budget,
1775 "status": r.status,
1776 "created_at": r.created_at,
1777 "started_at": r.started_at,
1778 "finished_at": r.finished_at,
1779 "cancel_requested": r.cancel_requested,
1780 "task": r.task,
1781 "progress": r.progress_text,
1782 "last_progress_at": r.last_progress_at,
1783 "input_tokens": r.input_tokens,
1784 "output_tokens": r.output_tokens,
1785 "artifact_json": r.artifact_json,
1786 })
1787 })
1788 .collect();
1789
1790 ToolResult::success(json!({"chat_id": chat_id, "runs": payload}).to_string())
1791 }
1792}
1793
1794async fn resolve_subagent_ref(
1797 db: &Arc<Database>,
1798 chat_id: i64,
1799 run_ref: &str,
1800) -> Result<Option<String>, String> {
1801 let run_ref_owned = run_ref.to_string();
1802 call_blocking(db.clone(), move |db| {
1803 db.resolve_subagent_run_id(chat_id, &run_ref_owned)
1804 })
1805 .await
1806 .map_err(|e| format!("Failed resolving subagent reference: {e}"))
1807}
1808
1809pub struct SubagentsInfoTool {
1810 db: Arc<Database>,
1811}
1812
1813impl SubagentsInfoTool {
1814 pub fn new(db: Arc<Database>) -> Self {
1815 Self { db }
1816 }
1817}
1818
1819#[async_trait]
1820impl Tool for SubagentsInfoTool {
1821 fn name(&self) -> &str {
1822 "subagents_info"
1823 }
1824
1825 fn definition(&self) -> ToolDefinition {
1826 ToolDefinition {
1827 name: "subagents_info".into(),
1828 description: "Get detailed information for one subagent run, by run id or task label."
1829 .into(),
1830 input_schema: schema_object(
1831 json!({
1832 "run_id": {"type": "string", "description": "Run id or the task label given at spawn."},
1833 "chat_id": {"type": "integer"}
1834 }),
1835 &["run_id"],
1836 ),
1837 }
1838 }
1839
1840 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1841 let auth =
1842 match auth_context_from_input(&input) {
1843 Some(v) => v,
1844 None => return ToolResult::error(
1845 "subagents_info requires caller auth context; run from an active chat session"
1846 .into(),
1847 ),
1848 };
1849 let chat_id = input
1850 .get("chat_id")
1851 .and_then(|v| v.as_i64())
1852 .unwrap_or(auth.caller_chat_id);
1853 if let Err(e) = authorize_chat_access(&input, chat_id) {
1854 return ToolResult::error(e);
1855 }
1856 let run_ref = match input.get("run_id").and_then(|v| v.as_str()) {
1857 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
1858 _ => return ToolResult::error("Missing required parameter: run_id".into()),
1859 };
1860 let run_id = match resolve_subagent_ref(&self.db, chat_id, &run_ref).await {
1861 Ok(Some(v)) => v,
1862 Ok(None) => return ToolResult::error(format!("No subagent run matching '{run_ref}'")),
1863 Err(e) => return ToolResult::error(e),
1864 };
1865
1866 let run = match call_blocking(self.db.clone(), move |db| {
1867 db.get_subagent_run(&run_id, chat_id)
1868 })
1869 .await
1870 {
1871 Ok(Some(v)) => v,
1872 Ok(None) => return ToolResult::error("Subagent run not found".into()),
1873 Err(e) => return ToolResult::error(format!("Failed reading subagent run: {e}")),
1874 };
1875
1876 ToolResult::success(
1877 json!({
1878 "run_id": run.run_id,
1879 "label": run.label,
1880 "parent_run_id": run.parent_run_id,
1881 "depth": run.depth,
1882 "chat_id": run.chat_id,
1883 "caller_channel": run.caller_channel,
1884 "task": run.task,
1885 "context": run.context,
1886 "status": run.status,
1887 "created_at": run.created_at,
1888 "started_at": run.started_at,
1889 "finished_at": run.finished_at,
1890 "cancel_requested": run.cancel_requested,
1891 "progress": run.progress_text,
1892 "last_progress_at": run.last_progress_at,
1893 "error_text": run.error_text,
1894 "result_text": run.result_text,
1895 "input_tokens": run.input_tokens,
1896 "output_tokens": run.output_tokens,
1897 "total_tokens": run.total_tokens,
1898 "provider": run.provider,
1899 "model": run.model,
1900 "token_budget": run.token_budget,
1901 "artifact_json": run.artifact_json,
1902 })
1903 .to_string(),
1904 )
1905 }
1906}
1907
1908pub struct SubagentsKillTool {
1909 config: Config,
1910 db: Arc<Database>,
1911}
1912
1913impl SubagentsKillTool {
1914 pub fn new(config: &Config, db: Arc<Database>) -> Self {
1915 Self {
1916 config: config.clone(),
1917 db,
1918 }
1919 }
1920}
1921
1922#[async_trait]
1923impl Tool for SubagentsKillTool {
1924 fn name(&self) -> &str {
1925 "subagents_kill"
1926 }
1927
1928 fn definition(&self) -> ToolDefinition {
1929 ToolDefinition {
1930 name: "subagents_kill".into(),
1931 description: "Request cancellation for one running subagent run (by run id or task label), or all active runs in current chat with run_id=all.".into(),
1932 input_schema: schema_object(
1933 json!({
1934 "run_id": {"type": "string", "description": "Run id, task label, or 'all'"},
1935 "chat_id": {"type": "integer"}
1936 }),
1937 &["run_id"],
1938 ),
1939 }
1940 }
1941
1942 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1943 let auth =
1944 match auth_context_from_input(&input) {
1945 Some(v) => v,
1946 None => return ToolResult::error(
1947 "subagents_kill requires caller auth context; run from an active chat session"
1948 .into(),
1949 ),
1950 };
1951 let chat_id = input
1952 .get("chat_id")
1953 .and_then(|v| v.as_i64())
1954 .unwrap_or(auth.caller_chat_id);
1955 if let Err(e) = authorize_chat_access(&input, chat_id) {
1956 return ToolResult::error(e);
1957 }
1958 let run_ref = match input.get("run_id").and_then(|v| v.as_str()) {
1959 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
1960 _ => return ToolResult::error("Missing required parameter: run_id".into()),
1961 };
1962
1963 let runtime = subagent_runtime(&self.config);
1964
1965 if run_ref.eq_ignore_ascii_case("all") {
1966 let rows = match call_blocking(self.db.clone(), move |db| {
1967 db.list_subagent_runs(chat_id, 200)
1968 })
1969 .await
1970 {
1971 Ok(v) => v,
1972 Err(e) => return ToolResult::error(format!("Failed listing subagent runs: {e}")),
1973 };
1974 let mut cancelled = 0usize;
1975 for row in rows {
1976 if matches!(row.status.as_str(), "accepted" | "queued" | "running") {
1977 let rid = row.run_id.clone();
1978 let requested = call_blocking(self.db.clone(), move |db| {
1979 db.request_subagent_cancel(&rid, chat_id)
1980 })
1981 .await
1982 .unwrap_or(false);
1983 if requested {
1984 runtime.cancel_run(&row.run_id);
1985 log_subagent_event(
1986 self.db.clone(),
1987 &row.run_id,
1988 "cancel_requested",
1989 Some("kill_all".to_string()),
1990 )
1991 .await;
1992 cancelled += 1;
1993 }
1994 }
1995 }
1996 return ToolResult::success(
1997 json!({"status": "ok", "cancelled": cancelled, "chat_id": chat_id}).to_string(),
1998 );
1999 }
2000
2001 let run_id = match resolve_subagent_ref(&self.db, chat_id, &run_ref).await {
2002 Ok(Some(v)) => v,
2003 Ok(None) => return ToolResult::error(format!("No subagent run matching '{run_ref}'")),
2004 Err(e) => return ToolResult::error(e),
2005 };
2006
2007 let run_id_for_db = run_id.clone();
2008 let requested = match call_blocking(self.db.clone(), move |db| {
2009 db.request_subagent_cancel(&run_id_for_db, chat_id)
2010 })
2011 .await
2012 {
2013 Ok(v) => v,
2014 Err(e) => {
2015 return ToolResult::error(format!("Failed requesting cancellation: {e}"));
2016 }
2017 };
2018
2019 if !requested {
2020 return ToolResult::error("Subagent run not found or already finished".into());
2021 }
2022 runtime.cancel_run(&run_id);
2023 log_subagent_event(
2024 self.db.clone(),
2025 &run_id,
2026 "cancel_requested",
2027 Some("kill_one".to_string()),
2028 )
2029 .await;
2030 ToolResult::success(json!({"status": "ok", "run_id": run_id}).to_string())
2031 }
2032}
2033
2034pub struct SubagentsRetryAnnouncesTool {
2035 config: Config,
2036 db: Arc<Database>,
2037 channel_registry: Arc<ChannelRegistry>,
2038}
2039
2040pub struct SubagentsFocusTool {
2041 db: Arc<Database>,
2042}
2043
2044impl SubagentsFocusTool {
2045 pub fn new(db: Arc<Database>) -> Self {
2046 Self { db }
2047 }
2048}
2049
2050#[async_trait]
2051impl Tool for SubagentsFocusTool {
2052 fn name(&self) -> &str {
2053 "subagents_focus"
2054 }
2055
2056 fn definition(&self) -> ToolDefinition {
2057 ToolDefinition {
2058 name: "subagents_focus".into(),
2059 description: "Bind the current chat to a subagent run for follow-up actions.".into(),
2060 input_schema: schema_object(
2061 json!({
2062 "run_id": {"type":"string"},
2063 "chat_id": {"type":"integer"}
2064 }),
2065 &["run_id"],
2066 ),
2067 }
2068 }
2069
2070 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2071 let auth = match auth_context_from_input(&input) {
2072 Some(v) => v,
2073 None => {
2074 return ToolResult::error("subagents_focus requires caller auth context".into())
2075 }
2076 };
2077 let chat_id = input
2078 .get("chat_id")
2079 .and_then(|v| v.as_i64())
2080 .unwrap_or(auth.caller_chat_id);
2081 if let Err(e) = authorize_chat_access(&input, chat_id) {
2082 return ToolResult::error(e);
2083 }
2084 let run_id = match input.get("run_id").and_then(|v| v.as_str()) {
2085 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
2086 _ => return ToolResult::error("Missing required parameter: run_id".into()),
2087 };
2088 let run_id_for_check = run_id.clone();
2089 let exists = match call_blocking(self.db.clone(), move |db| {
2090 db.get_subagent_run(&run_id_for_check, chat_id)
2091 })
2092 .await
2093 {
2094 Ok(v) => v.is_some(),
2095 Err(e) => return ToolResult::error(format!("Failed reading subagent run: {e}")),
2096 };
2097 if !exists {
2098 return ToolResult::error("Subagent run not found".into());
2099 }
2100 let run_id_for_set = run_id.clone();
2101 if let Err(e) = call_blocking(self.db.clone(), move |db| {
2102 db.set_subagent_focus(chat_id, &run_id_for_set)
2103 })
2104 .await
2105 {
2106 return ToolResult::error(format!("Failed setting subagent focus: {e}"));
2107 }
2108 ToolResult::success(json!({"status":"ok","chat_id":chat_id,"run_id":run_id}).to_string())
2109 }
2110}
2111
2112pub struct SubagentsUnfocusTool {
2113 db: Arc<Database>,
2114}
2115
2116impl SubagentsUnfocusTool {
2117 pub fn new(db: Arc<Database>) -> Self {
2118 Self { db }
2119 }
2120}
2121
2122#[async_trait]
2123impl Tool for SubagentsUnfocusTool {
2124 fn name(&self) -> &str {
2125 "subagents_unfocus"
2126 }
2127
2128 fn definition(&self) -> ToolDefinition {
2129 ToolDefinition {
2130 name: "subagents_unfocus".into(),
2131 description: "Clear focused subagent binding for the current chat.".into(),
2132 input_schema: schema_object(
2133 json!({
2134 "chat_id": {"type":"integer"}
2135 }),
2136 &[],
2137 ),
2138 }
2139 }
2140
2141 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2142 let auth = match auth_context_from_input(&input) {
2143 Some(v) => v,
2144 None => {
2145 return ToolResult::error("subagents_unfocus requires caller auth context".into())
2146 }
2147 };
2148 let chat_id = input
2149 .get("chat_id")
2150 .and_then(|v| v.as_i64())
2151 .unwrap_or(auth.caller_chat_id);
2152 if let Err(e) = authorize_chat_access(&input, chat_id) {
2153 return ToolResult::error(e);
2154 }
2155 if let Err(e) =
2156 call_blocking(self.db.clone(), move |db| db.clear_subagent_focus(chat_id)).await
2157 {
2158 return ToolResult::error(format!("Failed clearing subagent focus: {e}"));
2159 }
2160 ToolResult::success(json!({"status":"ok","chat_id":chat_id}).to_string())
2161 }
2162}
2163
2164pub struct SubagentsFocusedTool {
2165 db: Arc<Database>,
2166}
2167
2168impl SubagentsFocusedTool {
2169 pub fn new(db: Arc<Database>) -> Self {
2170 Self { db }
2171 }
2172}
2173
2174#[async_trait]
2175impl Tool for SubagentsFocusedTool {
2176 fn name(&self) -> &str {
2177 "subagents_focused"
2178 }
2179
2180 fn definition(&self) -> ToolDefinition {
2181 ToolDefinition {
2182 name: "subagents_focused".into(),
2183 description: "Show focused subagent binding for current chat.".into(),
2184 input_schema: schema_object(
2185 json!({
2186 "chat_id": {"type":"integer"}
2187 }),
2188 &[],
2189 ),
2190 }
2191 }
2192
2193 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2194 let auth = match auth_context_from_input(&input) {
2195 Some(v) => v,
2196 None => {
2197 return ToolResult::error("subagents_focused requires caller auth context".into())
2198 }
2199 };
2200 let chat_id = input
2201 .get("chat_id")
2202 .and_then(|v| v.as_i64())
2203 .unwrap_or(auth.caller_chat_id);
2204 if let Err(e) = authorize_chat_access(&input, chat_id) {
2205 return ToolResult::error(e);
2206 }
2207 let focused =
2208 match call_blocking(self.db.clone(), move |db| db.get_subagent_focus(chat_id)).await {
2209 Ok(v) => v,
2210 Err(e) => return ToolResult::error(format!("Failed loading subagent focus: {e}")),
2211 };
2212 ToolResult::success(json!({"chat_id":chat_id,"run_id":focused}).to_string())
2213 }
2214}
2215
2216pub struct SubagentsSendTool {
2217 config: Config,
2218 db: Arc<Database>,
2219 channel_registry: Arc<ChannelRegistry>,
2220}
2221
2222impl SubagentsSendTool {
2223 pub fn new(config: &Config, db: Arc<Database>, channel_registry: Arc<ChannelRegistry>) -> Self {
2224 Self {
2225 config: config.clone(),
2226 db,
2227 channel_registry,
2228 }
2229 }
2230}
2231
2232#[async_trait]
2233impl Tool for SubagentsSendTool {
2234 fn name(&self) -> &str {
2235 "subagents_send"
2236 }
2237
2238 fn definition(&self) -> ToolDefinition {
2239 ToolDefinition {
2240 name: "subagents_send".into(),
2241 description:
2242 "Send follow-up work to focused subagent by spawning a child continuation run."
2243 .into(),
2244 input_schema: schema_object(
2245 json!({
2246 "message": {"type":"string"},
2247 "chat_id": {"type":"integer"}
2248 }),
2249 &["message"],
2250 ),
2251 }
2252 }
2253
2254 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2255 let auth = match auth_context_from_input(&input) {
2256 Some(v) => v,
2257 None => return ToolResult::error("subagents_send requires caller auth context".into()),
2258 };
2259 let chat_id = input
2260 .get("chat_id")
2261 .and_then(|v| v.as_i64())
2262 .unwrap_or(auth.caller_chat_id);
2263 if let Err(e) = authorize_chat_access(&input, chat_id) {
2264 return ToolResult::error(e);
2265 }
2266 let message = match input.get("message").and_then(|v| v.as_str()) {
2267 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
2268 _ => return ToolResult::error("Missing required parameter: message".into()),
2269 };
2270 let focused_run =
2271 match call_blocking(self.db.clone(), move |db| db.get_subagent_focus(chat_id)).await {
2272 Ok(Some(v)) => v,
2273 Ok(None) => return ToolResult::error("No focused subagent for this chat".into()),
2274 Err(e) => return ToolResult::error(format!("Failed loading subagent focus: {e}")),
2275 };
2276 let focused_run_for_load = focused_run.clone();
2277 let parent = match call_blocking(self.db.clone(), move |db| {
2278 db.get_subagent_run(&focused_run_for_load, chat_id)
2279 })
2280 .await
2281 {
2282 Ok(Some(v)) => v,
2283 Ok(None) => return ToolResult::error("Focused subagent run not found".into()),
2284 Err(e) => return ToolResult::error(format!("Failed loading focused subagent: {e}")),
2285 };
2286
2287 let spawn_tool =
2288 SessionsSpawnTool::new(&self.config, self.db.clone(), self.channel_registry.clone());
2289 let remaining_budget = (parent.token_budget - parent.total_tokens).max(0);
2290 let parent_runtime = if parent.provider.starts_with("acp") {
2291 "acp"
2292 } else {
2293 "native"
2294 };
2295 let spawn_input = json!({
2296 "task": format!("Continuation request: {message}"),
2297 "context": format!("This is a follow-up sent to focused run {}. Continue the work based on prior run context and produce actionable output.", focused_run),
2298 "__microclaw_auth": {
2299 "caller_channel": auth.caller_channel,
2300 "caller_chat_id": chat_id,
2301 "control_chat_ids": auth.control_chat_ids,
2302 "env_files": auth.env_files,
2303 },
2304 "__subagent_runtime": {
2305 "run_id": parent.run_id,
2306 "depth": parent.depth,
2307 "runtime": parent_runtime,
2308 "runtime_target": crate::acp_subagent::acp_runtime_target_from_provider(&parent.provider),
2309 "token_budget_remaining": remaining_budget,
2310 }
2311 });
2312 spawn_tool.execute(spawn_input).await
2313 }
2314}
2315
2316pub struct SubagentsLogTool {
2317 db: Arc<Database>,
2318}
2319
2320struct OrchestratePackage {
2322 task: String,
2323 exit_criteria: Vec<crate::completion_contract::ExitCriterion>,
2324}
2325
2326const CONTRACT_FAILED_MARKER: &str = "[completion contract] FAILED";
2329
2330pub struct SubagentsOrchestrateTool {
2331 config: Config,
2332 db: Arc<Database>,
2333 channel_registry: Arc<ChannelRegistry>,
2334}
2335
2336impl SubagentsOrchestrateTool {
2337 pub fn new(config: &Config, db: Arc<Database>, channel_registry: Arc<ChannelRegistry>) -> Self {
2338 Self {
2339 config: config.clone(),
2340 db,
2341 channel_registry,
2342 }
2343 }
2344
2345 fn merge_run_artifacts(
2346 runs: &[crate::internal::storage::db::SubagentRunRecord],
2347 ) -> serde_json::Value {
2348 let mut summaries = Vec::new();
2349 let mut findings = BTreeSet::new();
2350 let mut next_actions = BTreeSet::new();
2351 let mut artifacts = Vec::new();
2352 for run in runs {
2353 let Some(raw) = run.artifact_json.as_deref() else {
2354 continue;
2355 };
2356 let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) else {
2357 continue;
2358 };
2359 if let Some(s) = v.get("summary").and_then(|x| x.as_str()) {
2360 if !s.trim().is_empty() {
2361 summaries.push(s.trim().to_string());
2362 }
2363 }
2364 if let Some(arr) = v.get("findings").and_then(|x| x.as_array()) {
2365 for f in arr.iter().filter_map(|x| x.as_str()) {
2366 if !f.trim().is_empty() {
2367 findings.insert(f.trim().to_string());
2368 }
2369 }
2370 }
2371 if let Some(arr) = v.get("next_actions").and_then(|x| x.as_array()) {
2372 for n in arr.iter().filter_map(|x| x.as_str()) {
2373 if !n.trim().is_empty() {
2374 next_actions.insert(n.trim().to_string());
2375 }
2376 }
2377 }
2378 if let Some(arr) = v.get("artifacts").and_then(|x| x.as_array()) {
2379 artifacts.extend(arr.iter().cloned());
2380 }
2381 }
2382 json!({
2383 "protocol": "orchestrate_merge_v1",
2384 "summary": summaries,
2385 "findings": findings.into_iter().collect::<Vec<_>>(),
2386 "next_actions": next_actions.into_iter().collect::<Vec<_>>(),
2387 "artifacts": artifacts,
2388 })
2389 }
2390}
2391
2392#[async_trait]
2393impl Tool for SubagentsOrchestrateTool {
2394 fn name(&self) -> &str {
2395 "subagents_orchestrate"
2396 }
2397
2398 fn definition(&self) -> ToolDefinition {
2399 ToolDefinition {
2400 name: "subagents_orchestrate".into(),
2401 description: "Depth=2 orchestration template: spawn multiple worker runs and optionally wait+merge structured artifacts.".into(),
2402 input_schema: schema_object(
2403 json!({
2404 "goal": {"type":"string"},
2405 "work_packages": {"type":"array", "items":{}, "description":"Work packages: plain strings, or objects {task, exit_criteria?} where exit_criteria is a completion contract (same shape as sessions_spawn) verified when the worker finishes. With wait=true, a worker whose contract FAILED is re-spawned once with the failure evidence (contract-aware fan-in retry)."},
2406 "chat_id": {"type":"integer"},
2407 "wait": {"type":"boolean"},
2408 "wait_timeout_secs": {"type":"integer", "minimum":1, "maximum":1200},
2409 "token_budget_total": {"type":"integer", "minimum":2000}
2410 }),
2411 &["goal", "work_packages"],
2412 ),
2413 }
2414 }
2415
2416 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2417 let auth = match auth_context_from_input(&input) {
2418 Some(v) => v,
2419 None => {
2420 return ToolResult::error(
2421 "subagents_orchestrate requires caller auth context".into(),
2422 )
2423 }
2424 };
2425 let chat_id = input
2426 .get("chat_id")
2427 .and_then(|v| v.as_i64())
2428 .unwrap_or(auth.caller_chat_id);
2429 if let Err(e) = authorize_chat_access(&input, chat_id) {
2430 return ToolResult::error(e);
2431 }
2432 let goal = match input.get("goal").and_then(|v| v.as_str()) {
2433 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
2434 _ => return ToolResult::error("Missing required parameter: goal".into()),
2435 };
2436 let raw_packages = input
2437 .get("work_packages")
2438 .and_then(|v| v.as_array())
2439 .cloned()
2440 .unwrap_or_default();
2441 let mut packages: Vec<OrchestratePackage> = Vec::new();
2442 for (i, v) in raw_packages.iter().enumerate() {
2443 if let Some(task) = v.as_str() {
2444 if !task.trim().is_empty() {
2445 packages.push(OrchestratePackage {
2446 task: task.trim().to_string(),
2447 exit_criteria: Vec::new(),
2448 });
2449 }
2450 continue;
2451 }
2452 if v.is_object() {
2453 let task = v
2454 .get("task")
2455 .and_then(|t| t.as_str())
2456 .map(str::trim)
2457 .unwrap_or_default()
2458 .to_string();
2459 if task.is_empty() {
2460 return ToolResult::error(format!(
2461 "work_packages[{i}] object is missing a non-empty `task`"
2462 ));
2463 }
2464 let exit_criteria =
2465 match crate::completion_contract::parse_exit_criteria(v.get("exit_criteria")) {
2466 Ok(c) => c,
2467 Err(e) => {
2468 return ToolResult::error(format!("work_packages[{i}]: {e}"));
2469 }
2470 };
2471 packages.push(OrchestratePackage {
2472 task,
2473 exit_criteria,
2474 });
2475 continue;
2476 }
2477 return ToolResult::error(format!(
2478 "work_packages[{i}] must be a string or an object {{task, exit_criteria?}}"
2479 ));
2480 }
2481 if packages.is_empty() {
2482 return ToolResult::error("work_packages must include at least one item".into());
2483 }
2484 let max_workers = self
2485 .config
2486 .subagents
2487 .orchestrate_max_workers
2488 .min(self.config.subagents.max_children_per_run);
2489 let wait = input.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
2490 let wait_timeout_secs = input
2491 .get("wait_timeout_secs")
2492 .and_then(|v| v.as_u64())
2493 .unwrap_or(120)
2494 .clamp(1, 1200);
2495 let total_budget = input
2496 .get("token_budget_total")
2497 .and_then(|v| v.as_i64())
2498 .filter(|v| *v > 0)
2499 .unwrap_or(self.config.subagents.max_tokens_per_run);
2500 let parent_run_id = input
2501 .get("__subagent_runtime")
2502 .and_then(|v| v.get("run_id"))
2503 .and_then(|v| v.as_str())
2504 .map(str::to_string);
2505 let parent_meta = subagent_runtime_meta_from_input(&input);
2506 let parent_remaining = parent_meta.as_ref().and_then(|m| m.token_budget_remaining);
2507 let total_budget = if let Some(remaining) = parent_remaining {
2508 total_budget.min(remaining.max(0))
2509 } else {
2510 total_budget
2511 };
2512 if total_budget < 2_000 {
2513 return ToolResult::error(format!(
2514 "subagent budget exhausted for orchestration: remaining {} < 2000",
2515 total_budget
2516 ));
2517 }
2518 let max_workers_by_budget = (total_budget / 2_000).max(1) as usize;
2519 let allowed_workers = max_workers.min(max_workers_by_budget).max(1);
2520 if packages.len() > allowed_workers {
2521 packages.truncate(allowed_workers);
2522 }
2523 let each_budget = (total_budget / packages.len() as i64).clamp(2_000, total_budget);
2524
2525 let spawn_tool =
2526 SessionsSpawnTool::new(&self.config, self.db.clone(), self.channel_registry.clone());
2527 let mut spawned = Vec::new();
2528 for (idx, pkg) in packages.iter().enumerate() {
2529 let mut spawn_input = json!({
2530 "task": format!("Work package {}: {}", idx + 1, pkg.task),
2531 "context": format!(
2532 "Orchestration goal: {goal}\nOutput strictly in protocol subagent_artifact_v1 with keys summary/findings/artifacts/next_actions/final_answer."
2533 ),
2534 "chat_id": chat_id,
2535 "token_budget": each_budget,
2536 "__microclaw_auth": {
2537 "caller_channel": auth.caller_channel.clone(),
2538 "caller_chat_id": chat_id,
2539 "control_chat_ids": auth.control_chat_ids.clone(),
2540 "env_files": auth.env_files.clone(),
2541 }
2542 });
2543 if !pkg.exit_criteria.is_empty() {
2544 if let Some(obj) = spawn_input.as_object_mut() {
2545 obj.insert(
2546 "exit_criteria".to_string(),
2547 serde_json::to_value(&pkg.exit_criteria).unwrap_or_default(),
2548 );
2549 }
2550 }
2551 let spawn_input = if let (Some(meta), Some(parent_run_id)) =
2552 (parent_meta.as_ref(), parent_run_id.as_deref())
2553 {
2554 let mut obj = spawn_input
2555 .as_object()
2556 .cloned()
2557 .unwrap_or_else(serde_json::Map::new);
2558 obj.insert(
2559 "__subagent_runtime".to_string(),
2560 json!({
2561 "run_id": parent_run_id,
2562 "depth": meta.depth,
2563 "runtime": meta.runtime.as_deref().unwrap_or("native"),
2564 "runtime_target": meta.runtime_target.clone(),
2565 "token_budget_remaining": each_budget,
2566 }),
2567 );
2568 serde_json::Value::Object(obj)
2569 } else {
2570 spawn_input
2571 };
2572 let res = spawn_tool.execute(spawn_input).await;
2573 if res.is_error {
2574 return res;
2575 }
2576 let parsed = match serde_json::from_str::<serde_json::Value>(&res.content) {
2577 Ok(v) => v,
2578 Err(e) => return ToolResult::error(format!("Invalid sessions_spawn output: {e}")),
2579 };
2580 let run_id = match parsed.get("run_id").and_then(|v| v.as_str()) {
2581 Some(v) => v.to_string(),
2582 None => return ToolResult::error("sessions_spawn did not return run_id".into()),
2583 };
2584 spawned.push(run_id);
2585 }
2586
2587 if !wait {
2588 return ToolResult::success(
2589 json!({
2590 "status": "accepted",
2591 "chat_id": chat_id,
2592 "goal": goal,
2593 "workers": spawned.len(),
2594 "run_ids": spawned,
2595 "token_budget_total": total_budget,
2596 "token_budget_each": each_budget,
2597 })
2598 .to_string(),
2599 );
2600 }
2601
2602 let deadline =
2603 std::time::Instant::now() + std::time::Duration::from_secs(wait_timeout_secs);
2604 let mut runs = Vec::new();
2605 let mut wait_timed_out = false;
2606 while std::time::Instant::now() < deadline {
2607 let ids = spawned.clone();
2608 let rows = match call_blocking(self.db.clone(), move |db| {
2609 let mut out = Vec::new();
2610 for run_id in ids {
2611 if let Some(row) = db.get_subagent_run(&run_id, chat_id)? {
2612 out.push(row);
2613 }
2614 }
2615 Ok::<_, microclaw_core::error::MicroClawError>(out)
2616 })
2617 .await
2618 {
2619 Ok(v) => v,
2620 Err(e) => return ToolResult::error(format!("Failed loading runs: {e}")),
2621 };
2622 let done = rows
2623 .iter()
2624 .all(|r| !matches!(r.status.as_str(), "accepted" | "queued" | "running"));
2625 runs = rows;
2626 if done {
2627 break;
2628 }
2629 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2630 }
2631 if runs
2632 .iter()
2633 .any(|r| matches!(r.status.as_str(), "accepted" | "queued" | "running"))
2634 {
2635 wait_timed_out = true;
2636 }
2637
2638 let mut retried: Vec<serde_json::Value> = Vec::new();
2644 if !wait_timed_out {
2645 let mut replacements: Vec<(usize, String)> = Vec::new(); for (idx, run) in runs.iter().enumerate() {
2647 if run.status != "completed" {
2648 continue;
2649 }
2650 let Some(result_text) = run.result_text.as_deref() else {
2651 continue;
2652 };
2653 if !result_text.trim_start().starts_with(CONTRACT_FAILED_MARKER) {
2654 continue;
2655 }
2656 let Some(pkg_idx) = spawned.iter().position(|id| *id == run.run_id) else {
2658 continue;
2659 };
2660 let Some(pkg) = packages.get(pkg_idx) else {
2661 continue;
2662 };
2663 let evidence_end = microclaw_core::text::floor_char_boundary(result_text, 600);
2664 let mut spawn_input = json!({
2665 "task": format!("Work package {} (retry): {}", pkg_idx + 1, pkg.task),
2666 "context": format!(
2667 "Orchestration goal: {goal}\nA previous attempt FAILED its completion contract. Evidence:\n{}\nComplete the unmet criteria.\nOutput strictly in protocol subagent_artifact_v1 with keys summary/findings/artifacts/next_actions/final_answer.",
2668 &result_text[..evidence_end]
2669 ),
2670 "chat_id": chat_id,
2671 "token_budget": each_budget,
2672 "exit_criteria": serde_json::to_value(&pkg.exit_criteria).unwrap_or_default(),
2673 "__microclaw_auth": {
2674 "caller_channel": auth.caller_channel.clone(),
2675 "caller_chat_id": chat_id,
2676 "control_chat_ids": auth.control_chat_ids.clone(),
2677 "env_files": auth.env_files.clone(),
2678 }
2679 });
2680 if let (Some(meta), Some(parent_run_id)) =
2681 (parent_meta.as_ref(), parent_run_id.as_deref())
2682 {
2683 if let Some(obj) = spawn_input.as_object_mut() {
2684 obj.insert(
2685 "__subagent_runtime".to_string(),
2686 json!({
2687 "run_id": parent_run_id,
2688 "depth": meta.depth,
2689 "runtime": meta.runtime.as_deref().unwrap_or("native"),
2690 "runtime_target": meta.runtime_target.clone(),
2691 "token_budget_remaining": each_budget,
2692 }),
2693 );
2694 }
2695 }
2696 let res = spawn_tool.execute(spawn_input).await;
2697 if res.is_error {
2698 continue;
2700 }
2701 let new_run_id = serde_json::from_str::<serde_json::Value>(&res.content)
2702 .ok()
2703 .and_then(|v| v.get("run_id").and_then(|r| r.as_str()).map(str::to_string));
2704 if let Some(new_run_id) = new_run_id {
2705 retried.push(json!({
2706 "package": pkg_idx + 1,
2707 "failed_run_id": run.run_id.clone(),
2708 "retry_run_id": new_run_id.clone(),
2709 }));
2710 replacements.push((idx, new_run_id));
2711 }
2712 }
2713
2714 if !replacements.is_empty() {
2715 let retry_ids: Vec<String> =
2717 replacements.iter().map(|(_, id)| id.clone()).collect();
2718 loop {
2719 let ids = retry_ids.clone();
2720 let rows = match call_blocking(self.db.clone(), move |db| {
2721 let mut out = Vec::new();
2722 for run_id in ids {
2723 if let Some(row) = db.get_subagent_run(&run_id, chat_id)? {
2724 out.push(row);
2725 }
2726 }
2727 Ok::<_, microclaw_core::error::MicroClawError>(out)
2728 })
2729 .await
2730 {
2731 Ok(v) => v,
2732 Err(e) => return ToolResult::error(format!("Failed loading runs: {e}")),
2733 };
2734 let done = rows
2735 .iter()
2736 .all(|r| !matches!(r.status.as_str(), "accepted" | "queued" | "running"));
2737 if done {
2738 for (idx, retry_id) in &replacements {
2740 if let Some(row) = rows.iter().find(|r| r.run_id == *retry_id) {
2741 runs[*idx] = row.clone();
2742 }
2743 }
2744 break;
2745 }
2746 if std::time::Instant::now() >= deadline {
2747 wait_timed_out = true;
2748 break;
2749 }
2750 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2751 }
2752 }
2753 }
2754
2755 let merged = Self::merge_run_artifacts(&runs);
2756 ToolResult::success(
2757 json!({
2758 "status": if wait_timed_out { "timeout_partial" } else { "merged" },
2759 "chat_id": chat_id,
2760 "goal": goal,
2761 "workers": spawned.len(),
2762 "run_ids": spawned,
2763 "retried": retried,
2764 "runs": runs.into_iter().map(|r| json!({
2765 "run_id": r.run_id,
2766 "status": r.status,
2767 "depth": r.depth,
2768 "token_budget": r.token_budget,
2769 "total_tokens": r.total_tokens,
2770 "artifact_json": r.artifact_json,
2771 })).collect::<Vec<_>>(),
2772 "merged": merged,
2773 })
2774 .to_string(),
2775 )
2776 }
2777}
2778
2779impl SubagentsLogTool {
2780 pub fn new(db: Arc<Database>) -> Self {
2781 Self { db }
2782 }
2783}
2784
2785#[async_trait]
2786impl Tool for SubagentsLogTool {
2787 fn name(&self) -> &str {
2788 "subagents_log"
2789 }
2790
2791 fn definition(&self) -> ToolDefinition {
2792 ToolDefinition {
2793 name: "subagents_log".into(),
2794 description: "Get timeline events for one subagent run.".into(),
2795 input_schema: schema_object(
2796 json!({
2797 "run_id": {"type":"string"},
2798 "chat_id": {"type":"integer"},
2799 "limit": {"type":"integer", "minimum":1, "maximum":200}
2800 }),
2801 &["run_id"],
2802 ),
2803 }
2804 }
2805
2806 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2807 let auth = match auth_context_from_input(&input) {
2808 Some(v) => v,
2809 None => return ToolResult::error("subagents_log requires caller auth context".into()),
2810 };
2811 let chat_id = input
2812 .get("chat_id")
2813 .and_then(|v| v.as_i64())
2814 .unwrap_or(auth.caller_chat_id);
2815 if let Err(e) = authorize_chat_access(&input, chat_id) {
2816 return ToolResult::error(e);
2817 }
2818 let run_id = match input.get("run_id").and_then(|v| v.as_str()) {
2819 Some(v) if !v.trim().is_empty() => v.trim().to_string(),
2820 _ => return ToolResult::error("Missing required parameter: run_id".into()),
2821 };
2822 let run_id_for_check = run_id.clone();
2823 let run_exists = match call_blocking(self.db.clone(), move |db| {
2824 db.get_subagent_run(&run_id_for_check, chat_id)
2825 })
2826 .await
2827 {
2828 Ok(v) => v.is_some(),
2829 Err(e) => return ToolResult::error(format!("Failed reading subagent run: {e}")),
2830 };
2831 if !run_exists {
2832 return ToolResult::error("Subagent run not found".into());
2833 }
2834 let limit = input
2835 .get("limit")
2836 .and_then(|v| v.as_u64())
2837 .unwrap_or(50)
2838 .clamp(1, 200) as usize;
2839 let run_id_for_events = run_id.clone();
2840 let events = match call_blocking(self.db.clone(), move |db| {
2841 db.list_subagent_events(&run_id_for_events, limit)
2842 })
2843 .await
2844 {
2845 Ok(v) => v,
2846 Err(e) => return ToolResult::error(format!("Failed listing subagent events: {e}")),
2847 };
2848 let payload: Vec<serde_json::Value> = events
2849 .into_iter()
2850 .map(|e| {
2851 json!({
2852 "id": e.id,
2853 "run_id": e.run_id,
2854 "event_type": e.event_type,
2855 "detail": e.detail,
2856 "created_at": e.created_at
2857 })
2858 })
2859 .collect();
2860 ToolResult::success(json!({"run_id": run_id, "events": payload}).to_string())
2861 }
2862}
2863
2864impl SubagentsRetryAnnouncesTool {
2865 pub fn new(config: &Config, db: Arc<Database>, channel_registry: Arc<ChannelRegistry>) -> Self {
2866 Self {
2867 config: config.clone(),
2868 db,
2869 channel_registry,
2870 }
2871 }
2872}
2873
2874#[async_trait]
2875impl Tool for SubagentsRetryAnnouncesTool {
2876 fn name(&self) -> &str {
2877 "subagents_retry_announces"
2878 }
2879
2880 fn definition(&self) -> ToolDefinition {
2881 ToolDefinition {
2882 name: "subagents_retry_announces".into(),
2883 description:
2884 "Manually flush pending subagent completion announcements (control chats only)."
2885 .into(),
2886 input_schema: schema_object(
2887 json!({
2888 "batch": {"type": "integer", "minimum": 1, "maximum": 200}
2889 }),
2890 &[],
2891 ),
2892 }
2893 }
2894
2895 async fn execute(&self, input: serde_json::Value) -> ToolResult {
2896 let auth = match auth_context_from_input(&input) {
2897 Some(v) => v,
2898 None => {
2899 return ToolResult::error(
2900 "subagents_retry_announces requires caller auth context".into(),
2901 )
2902 }
2903 };
2904 if !auth.is_control_chat() {
2905 return ToolResult::error(
2906 "Permission denied: subagents_retry_announces requires control chat".into(),
2907 );
2908 }
2909 let batch = input
2910 .get("batch")
2911 .and_then(|v| v.as_u64())
2912 .unwrap_or(50)
2913 .clamp(1, 200) as usize;
2914 let _ = flush_pending_announces_once(
2915 &self.config,
2916 self.channel_registry.clone(),
2917 self.db.clone(),
2918 batch,
2919 )
2920 .await;
2921 ToolResult::success(json!({"status":"ok","batch":batch}).to_string())
2922 }
2923}
2924
2925#[cfg(test)]
2926mod tests {
2927 use super::*;
2928 use crate::config::WorkingDirIsolation;
2929
2930 fn test_config() -> Config {
2931 let mut cfg = Config::test_defaults();
2932 cfg.model = "claude-test".into();
2933 cfg.max_tokens = 2048;
2934 cfg.data_dir = "/tmp".into();
2935 cfg.working_dir = "/tmp".into();
2936 cfg.working_dir_isolation = WorkingDirIsolation::Shared;
2937 cfg.web_enabled = false;
2938 cfg
2939 }
2940
2941 fn test_db() -> Arc<Database> {
2942 let dir = std::env::temp_dir().join(format!(
2943 "microclaw_subagents_tool_test_{}",
2944 uuid::Uuid::new_v4()
2945 ));
2946 std::fs::create_dir_all(&dir).unwrap();
2947 Arc::new(Database::new(dir.to_str().unwrap()).unwrap())
2948 }
2949
2950 #[tokio::test]
2951 async fn test_sessions_spawn_requires_task() {
2952 let tool =
2953 SessionsSpawnTool::new(&test_config(), test_db(), Arc::new(ChannelRegistry::new()));
2954 let result = tool
2955 .execute(json!({"__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1}}))
2956 .await;
2957 assert!(result.is_error);
2958 assert!(result.content.contains("task"));
2959 }
2960
2961 #[tokio::test]
2962 async fn test_sessions_spawn_rejects_unknown_runtime() {
2963 let tool =
2964 SessionsSpawnTool::new(&test_config(), test_db(), Arc::new(ChannelRegistry::new()));
2965 let result = tool
2966 .execute(json!({
2967 "task": "run",
2968 "runtime": "mystery",
2969 "__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1}
2970 }))
2971 .await;
2972 assert!(result.is_error);
2973 assert!(result.content.contains("Unsupported subagent runtime"));
2974 }
2975
2976 #[tokio::test]
2977 async fn test_sessions_spawn_rejects_disabled_acp_runtime() {
2978 let tool =
2979 SessionsSpawnTool::new(&test_config(), test_db(), Arc::new(ChannelRegistry::new()));
2980 let result = tool
2981 .execute(json!({
2982 "task": "run",
2983 "runtime": "acp",
2984 "__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1}
2985 }))
2986 .await;
2987 assert!(result.is_error);
2988 assert!(result.content.contains("ACP runtime is disabled"));
2989 }
2990
2991 #[tokio::test]
2992 async fn test_sessions_spawn_rejects_unknown_acp_target() {
2993 let mut cfg = test_config();
2994 cfg.subagents.acp.default_target.enabled = true;
2995 cfg.subagents.acp.default_target.command = "codex".into();
2996 let tool = SessionsSpawnTool::new(&cfg, test_db(), Arc::new(ChannelRegistry::new()));
2997 let result = tool
2998 .execute(json!({
2999 "task": "run",
3000 "runtime": "acp",
3001 "runtime_target": "missing",
3002 "__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1}
3003 }))
3004 .await;
3005 assert!(result.is_error);
3006 assert!(result.content.contains("Unknown ACP runtime target"));
3007 }
3008
3009 #[tokio::test]
3010 async fn test_sessions_spawn_inherits_acp_runtime_target_from_parent_meta() {
3011 let mut cfg = test_config();
3012 cfg.subagents.acp.default_target.enabled = true;
3013 cfg.subagents.acp.targets.insert(
3014 "worker".into(),
3015 crate::config::SubagentAcpTargetConfig {
3016 enabled: true,
3017 command: "codex".into(),
3018 ..crate::config::SubagentAcpTargetConfig::default()
3019 },
3020 );
3021 let tool = SessionsSpawnTool::new(&cfg, test_db(), Arc::new(ChannelRegistry::new()));
3022 let result = tool
3023 .execute(json!({
3024 "task": "run",
3025 "__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1},
3026 "__subagent_runtime": {
3027 "run_id": "parent",
3028 "depth": 0,
3029 "runtime": "acp",
3030 "runtime_target": "worker",
3031 "token_budget_remaining": 8000
3032 }
3033 }))
3034 .await;
3035 assert!(!result.is_error);
3036 let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
3037 assert_eq!(parsed.get("runtime").and_then(|v| v.as_str()), Some("acp"));
3038 assert_eq!(
3039 parsed.get("runtime_target").and_then(|v| v.as_str()),
3040 Some("worker")
3041 );
3042 }
3043
3044 #[tokio::test]
3045 async fn test_subagents_info_requires_run_id() {
3046 let tool = SubagentsInfoTool::new(test_db());
3047 let result = tool
3048 .execute(json!({"__microclaw_auth": {"caller_channel":"web", "caller_chat_id": 1}}))
3049 .await;
3050 assert!(result.is_error);
3051 assert!(result.content.contains("run_id"));
3052 }
3053
3054 #[test]
3055 fn test_normalize_strips_leaked_thinking() {
3056 let raw = "<think>The write_memory tool is not available - I see read_memory \
3059 but not write_memory. Let me inform the user.</think>";
3060 let (answer, envelope) = normalize_subagent_artifact_payload(raw);
3061 assert!(!answer.contains("<think>"));
3062 assert!(!answer.contains("write_memory"));
3063 assert!(!envelope.contains("<think>"));
3064 }
3065
3066 #[test]
3067 fn contract_failed_marker_matches_report_rendering() {
3068 let report = crate::completion_contract::render_report(&[
3071 crate::completion_contract::CriterionOutcome {
3072 description: "file `x` exists".into(),
3073 passed: false,
3074 evidence: "missing".into(),
3075 },
3076 ]);
3077 assert!(report.starts_with(CONTRACT_FAILED_MARKER));
3078 let ok_report = crate::completion_contract::render_report(&[
3080 crate::completion_contract::CriterionOutcome {
3081 description: "file `x` exists".into(),
3082 passed: true,
3083 evidence: "ok".into(),
3084 },
3085 ]);
3086 assert!(!ok_report.starts_with(CONTRACT_FAILED_MARKER));
3087 }
3088
3089 #[test]
3090 fn test_submit_result_tool_definition_shape() {
3091 let def = submit_result_tool_definition();
3092 assert_eq!(def.name, SUBMIT_RESULT_TOOL);
3093 let props = def.input_schema.get("properties").unwrap();
3094 for key in [
3095 "summary",
3096 "findings",
3097 "artifacts",
3098 "next_actions",
3099 "final_answer",
3100 ] {
3101 assert!(props.get(key).is_some(), "missing schema property {key}");
3102 }
3103 let required = def
3104 .input_schema
3105 .get("required")
3106 .unwrap()
3107 .as_array()
3108 .unwrap();
3109 assert!(required.iter().any(|v| v == "final_answer"));
3110 let payload = json!({"summary": "s", "final_answer": "the answer"}).to_string();
3112 let (answer, _envelope) = normalize_subagent_artifact_payload(&payload);
3113 assert_eq!(answer, "the answer");
3114 }
3115
3116 #[test]
3117 fn test_subagent_output_is_structured() {
3118 assert!(!subagent_output_is_structured(
3120 "<think>no write_memory tool</think> I can't save this."
3121 ));
3122 assert!(!subagent_output_is_structured(
3123 "Done, everything looks good."
3124 ));
3125 assert!(subagent_output_is_structured(
3127 "<think>ok</think>{\"summary\":\"s\",\"final_answer\":\"done\"}"
3128 ));
3129 assert!(!subagent_output_is_structured(
3131 "{\"summary\":\"\",\"final_answer\":\"\"}"
3132 ));
3133 }
3134
3135 #[test]
3136 fn test_normalize_recovers_json_after_thinking() {
3137 let raw = "<think>let me structure this</think>\n\
3140 {\"summary\":\"done\",\"findings\":[\"a\"],\"artifacts\":[],\
3141 \"next_actions\":[],\"final_answer\":\"All set.\"}";
3142 let (answer, _envelope) = normalize_subagent_artifact_payload(raw);
3143 assert_eq!(answer, "All set.");
3144 }
3145
3146 #[test]
3147 fn test_compute_child_token_budget_respects_parent_remaining() {
3148 let budget = compute_child_token_budget(Some(5000), Some(3000), 120000).unwrap();
3149 assert_eq!(budget, 3000);
3150 }
3151
3152 #[test]
3153 fn test_compute_child_token_budget_rejects_exhausted_parent() {
3154 let err = compute_child_token_budget(None, Some(1500), 120000).unwrap_err();
3155 assert!(err.contains("budget exhausted"));
3156 }
3157
3158 #[test]
3159 fn process_lifetime_executor_outlives_foreground_runtime() {
3160 let (tx, rx) = std::sync::mpsc::channel();
3161 {
3162 let foreground = tokio::runtime::Runtime::new().unwrap();
3163 foreground.block_on(async {
3164 TASK_RUNTIME.spawn(async move {
3165 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3166 tx.send("finished").unwrap();
3167 });
3168 });
3169 }
3170 assert_eq!(
3171 rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(),
3172 "finished"
3173 );
3174 }
3175}