Skip to main content

microclaw_engine/tools/
subagents.rs

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
116/// Name of the synthetic tool a sub-agent calls to return its final structured
117/// result. Using a tool input schema is the provider-native way to constrain the
118/// shape of the output (validated function/tool arguments on both Anthropic and
119/// OpenAI), which is far more reliable than parsing free-text JSON.
120const SUBMIT_RESULT_TOOL: &str = "submit_result";
121
122/// The contract schema, expressed as a tool so the model emits it as validated
123/// tool input rather than free text.
124fn 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
145/// Whether the sub-agent's terminal text actually carries the structured
146/// output contract — a JSON object with a non-empty `summary` or `final_answer`
147/// — as opposed to raw prose or leaked reasoning. Drives the one-shot repair
148/// re-ask in the sub-agent loop before falling back to best-effort
149/// normalization.
150pub(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    // Strip any chain-of-thought the model emitted so it never leaks into the
180    // chat announcement, and so a JSON object that follows a <think> block can
181    // still be parsed (a leading reasoning block makes a whole-string parse fail).
182    let cleaned = crate::agent_engine::sanitize_user_visible_text(raw_text);
183    let text = cleaned.trim();
184    // Prefer a clean whole-string parse; otherwise recover an embedded {...}
185    // object (model wrapped the JSON in prose) so we don't fall back to dumping
186    // raw reasoning as the answer.
187    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
304// Subagent runs must outlive the Tokio runtime of the foreground caller. Work
305// intentionally creates a short-lived runtime for each turn; spawning on that
306// runtime used to abort accepted background runs as soon as the Main Agent
307// finished. A process-lifetime executor gives Server and Work the same durable
308// in-process lifecycle while SQLite remains the restart recovery authority.
309static 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
330/// Shared cancellation boundary for product UIs that already resolved and
331/// authorized the owning chat. The database flag survives process failure;
332/// the in-memory flag interrupts a currently executing run promptly.
333pub 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
392/// Bash-backed runner for `Command` exit criteria: routes through the
393/// sub-agent's own tool registry, so the sandbox router, dangerous-pattern
394/// checks, and tool_policy apply exactly as for agent-issued commands.
395struct 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
411/// Result of verifying a finished sub-agent result against its contract.
412struct ContractVerdict {
413    /// Result text with the evidence report prepended.
414    annotated: String,
415    /// The report alone (used to prompt the bounded in-run retry).
416    report: String,
417    passed: bool,
418}
419
420/// Verify a finished result against its completion contract (if any) and
421/// build the evidence report, so the parent loop and the user see
422/// VERIFIED/FAILED with per-criterion evidence instead of trusting the
423/// sub-agent's self-report. Returns `None` when no contract was declared.
424#[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
481/// Prompt sent back into the sub-agent conversation for the single bounded
482/// contract retry.
483fn 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        // Contracts on the ACP runtime: the external agent's conversation is
515        // not ours to continue, so the bounded retry is a full re-run with
516        // the failure evidence appended to the context.
517        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        // Verification (and its command runner) go through our own sub-agent
540        // tool registry — same sandbox/policy guards as the native runtime.
541        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    // The sub-agent's normal tools plus a synthetic `submit_result` tool used to
598    // return the final structured result as validated tool input (native
599    // constrained decoding) rather than hand-written JSON text.
600    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    // Bounded (one-shot) repair: if the model ends without the required JSON
627    // contract, we re-ask exactly once before falling back to best-effort
628    // normalization. Prevents an unbounded re-ask loop.
629    let mut repair_attempted = false;
630    // Bounded contract retry: when the completion contract fails, give the
631    // sub-agent exactly ONE chance to finish the unmet criteria before the
632    // failed result is returned as-is.
633    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            // One-shot repair re-ask: the contract requires a JSON object, but
689            // models sometimes stop on prose or leaked reasoning. Ask once for
690            // clean JSON before falling back to best-effort normalization. Only
691            // for a natural `end_turn`; a `max_tokens` stop is truncation, where
692            // re-asking would just burn more budget without fixing the shape.
693            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 the model called `submit_result`, its (schema-validated) input
764            // IS the final structured result — short-circuit and return it.
765            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                        // Keep the tool_use/tool_result pairing the provider
789                        // requires: echo the assistant turn (including the
790                        // submit_result call), then answer that call with the
791                        // failure report.
792                        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
995/// When the last child of a parent finishes, post one consolidated summary of
996/// the whole batch. Idempotent: the announce is keyed on `<parent>:fanin`, whose
997/// UNIQUE constraint means only one fan-in message is ever enqueued even if
998/// several children finish at once.
999async 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    // Only summarize genuine batches (the single-child case is covered by its
1029    // own completion message).
1030    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    // A UNIQUE violation here just means another sibling already enqueued it.
1073    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        // Resolve the requested specialist (falls back to generalist for unknown/empty).
1287        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        // Optional human-friendly label ("competitor research") for "what am I working on".
1298        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            // Surface completion into the parent turn's heartbeat if it is
1556            // still running (no-op otherwise).
1557            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            // Fan-in: when this was the last active child of a parent run, post one
1664            // consolidated summary of the whole batch (opt-in).
1665            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
1794/// Resolve a run reference (exact run_id or human-friendly label) to a run_id
1795/// within the given chat. Shared by subagents_info / subagents_kill.
1796async 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
2320/// One orchestrate work package: a task plus its optional completion contract.
2321struct OrchestratePackage {
2322    task: String,
2323    exit_criteria: Vec<crate::completion_contract::ExitCriterion>,
2324}
2325
2326/// Marker prefix a failed contract leaves at the head of result_text
2327/// (produced by completion_contract::render_report).
2328const 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        // Contract-aware fan-in retry: a completed worker whose result carries
2639        // the FAILED contract marker gets exactly ONE replacement run, spawned
2640        // with the failure evidence as extra context. (The in-run bounded
2641        // retry has already happened inside the worker, so this catches the
2642        // cases where the worker gave up.)
2643        let mut retried: Vec<serde_json::Value> = Vec::new();
2644        if !wait_timed_out {
2645            let mut replacements: Vec<(usize, String)> = Vec::new(); // (runs idx, new run_id)
2646            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                // Recover the package (spawn order matches `packages`).
2657                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                    // Budget/depth exhausted: keep the failed result as-is.
2699                    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                // Wait for the replacement runs within the remaining deadline.
2716                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                        // Swap the finished replacements into the merge set.
2739                        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        // A sub-agent that emits only chain-of-thought (no JSON) must not leak
3057        // the <think> block into the announced answer.
3058        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        // The fan-in retry detects failed contracts by this marker; it must
3069        // stay in sync with completion_contract::render_report.
3070        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        // And the VERIFIED path must NOT match the failure marker.
3079        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        // A submit_result payload normalizes into the announced answer.
3111        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        // Prose / leaked reasoning is NOT structured (would trigger a repair).
3119        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        // A contract object (even after a thinking block) IS structured.
3126        assert!(subagent_output_is_structured(
3127            "<think>ok</think>{\"summary\":\"s\",\"final_answer\":\"done\"}"
3128        ));
3129        // An object with only empty contract fields is not structured.
3130        assert!(!subagent_output_is_structured(
3131            "{\"summary\":\"\",\"final_answer\":\"\"}"
3132        ));
3133    }
3134
3135    #[test]
3136    fn test_normalize_recovers_json_after_thinking() {
3137        // Reasoning block followed by the contract JSON: the JSON must win, not
3138        // the raw text fallback.
3139        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}