Skip to main content

navi_core/tool/builtin/
subagent.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, RwLock, Weak};
4use std::time::Instant;
5
6use anyhow::{Context, Result};
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use tokio::sync::mpsc;
11
12use super::helpers;
13use crate::background_model::BackgroundModelResolver;
14use crate::cancel::CancelToken;
15use crate::compact::CompactState;
16use crate::config::{HarnessConfig, LoadedConfig, NaviConfig};
17use crate::event::{AgentEvent, ApprovalDecision, SubagentTranscriptItem, SubagentTranscriptKind};
18use crate::model::{ModelMessage, ModelProvider, ModelRole};
19use crate::prompt::PromptCache;
20use crate::runtime::ApprovalResolver;
21use crate::runtime_components::RuntimeComponents;
22use crate::session::SessionStore;
23use crate::tool::{
24    Tool, ToolDefinition, ToolInvocation, ToolInvocationContext, ToolKind, ToolResult,
25};
26use crate::turn::TurnContext;
27use serde_json::Value;
28
29/// Pre-defined agent role profiles that influence tool availability and approval flow.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum AgentProfile {
33    /// Plans tasks and decomposes work. No write tool access.
34    Planner,
35    /// Reads files and searches the codebase. No write tool access.
36    Explorer,
37    /// Writes and edits code. Full write access, normal approvals.
38    Implementer,
39    /// Reviews code and proposes changes without applying them.
40    Reviewer,
41    /// Reviews security effects, capability use, and sensitive diffs.
42    SecurityReviewer,
43    /// Runs tests and verifies changes. Read-only access.
44    Verifier,
45    /// Summarizes conversations, code, or documentation.
46    Summarizer,
47}
48
49/// Controls how the subagent handles tool approvals.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ApprovalMode {
53    /// Inherit the parent session's approval policy (default).
54    Inherit,
55    /// Route approval requests to the parent session for user decision.
56    Escalate,
57    /// Reject all write operations. The subagent can only read/query.
58    ReadOnly,
59    /// Deny write-oriented tools but allow read-only inspection and verifier commands.
60    DenyWrite,
61}
62
63/// Optional configuration for subagent behavior.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct SubagentOptions {
66    /// The agent role profile. Influences default tool access and approvals.
67    #[serde(
68        default,
69        skip_serializing_if = "Option::is_none",
70        rename = "agent_profile"
71    )]
72    pub profile: Option<AgentProfile>,
73    /// Override the model used by this subagent.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub model: Option<String>,
76    /// Restrict which tools the subagent may call. `None` = all tools available.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub tools: Option<Vec<String>>,
79    /// Approval handling mode.
80    #[serde(default)]
81    pub approval: ApprovalMode,
82    /// Maximum tokens for the subagent response.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub max_tokens: Option<usize>,
85    /// Workflow write-path envelope (when set, forks executor with WritePathScope).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub write_allow: Option<Vec<String>>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub path_deny: Option<Vec<String>>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub create_files: Option<bool>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub create_dirs: Option<bool>,
94}
95
96impl Default for ApprovalMode {
97    fn default() -> Self {
98        Self::Inherit
99    }
100}
101
102const MAX_BACKGROUND_SUBAGENTS: usize = 8;
103/// Nested agent spawners must not be available inside subagents.
104/// `repo_explore` is now BM25+symbols (cheap) and is allowed for subagents.
105const NESTED_AGENT_TOOLS: &[&str] = &["subagent", "workflow"];
106/// Tool names considered to be "write" operations for ReadOnly mode.
107const READONLY_DENIED_TOOLS: &[&str] = &[
108    "write",
109    "write_file",
110    "apply_patch",
111    "code_edit",
112    "code_exec",
113    "bash",
114    "sandbox",
115    "package_manager",
116    "mark_feature_done",
117    "append_note",
118    "question",
119    "plan",
120];
121const WRITE_DENIED_TOOLS: &[&str] = &[
122    "write",
123    "write_file",
124    "apply_patch",
125    "code_edit",
126    "code_exec",
127    "sandbox",
128    "package_manager",
129    "mark_feature_done",
130    "append_note",
131];
132
133/// Callback for building a `ModelProvider` from a `LoadedConfig`.
134pub type ProviderBuilderFn =
135    dyn Fn(&LoadedConfig) -> anyhow::Result<Arc<dyn ModelProvider>> + Send + Sync;
136
137pub struct SubagentTool {
138    tool_executor: Weak<crate::tool::ToolExecutor>,
139    model_provider: Arc<RwLock<Arc<dyn ModelProvider>>>,
140    project_dir: std::path::PathBuf,
141    model_name: Arc<RwLock<String>>,
142    harness_config: HarnessConfig,
143    config: Arc<RwLock<NaviConfig>>,
144    /// Kept for constructor API stability. Nested turns use a fresh cache so
145    /// parent session prefix-cache keys are not poisoned by subagent prompts.
146    _prompt_cache: Arc<PromptCache>,
147    components: RuntimeComponents,
148    background_tasks: tokio::sync::Mutex<HashMap<String, Arc<SubagentBackgroundTask>>>,
149    next_task_id: AtomicU64,
150    /// Optional resolver for selecting background models by profile.
151    background_resolver: Option<Arc<BackgroundModelResolver>>,
152    /// Data directory for building providers.
153    data_dir: std::path::PathBuf,
154    /// Callback for building a provider from config.
155    provider_builder: Option<Arc<ProviderBuilderFn>>,
156}
157
158impl SubagentTool {
159    pub fn new(
160        tool_executor: Weak<crate::tool::ToolExecutor>,
161        model_provider: Arc<RwLock<Arc<dyn ModelProvider>>>,
162        project_dir: std::path::PathBuf,
163        data_dir: std::path::PathBuf,
164        model_name: Arc<RwLock<String>>,
165        harness_config: HarnessConfig,
166        config: Arc<RwLock<NaviConfig>>,
167        prompt_cache: Arc<PromptCache>,
168        components: RuntimeComponents,
169    ) -> Self {
170        Self {
171            tool_executor,
172            model_provider,
173            project_dir,
174            data_dir,
175            model_name,
176            harness_config,
177            config,
178            _prompt_cache: prompt_cache,
179            components,
180            background_tasks: tokio::sync::Mutex::new(HashMap::new()),
181            next_task_id: AtomicU64::new(1),
182            background_resolver: None,
183            provider_builder: None,
184        }
185    }
186
187    /// Sets the background model resolver for profile-based model selection.
188    pub fn with_background_resolver(
189        mut self,
190        resolver: Arc<BackgroundModelResolver>,
191        data_dir: std::path::PathBuf,
192        provider_builder: Arc<ProviderBuilderFn>,
193    ) -> Self {
194        self.background_resolver = Some(resolver);
195        self.data_dir = data_dir;
196        self.provider_builder = Some(provider_builder);
197        self
198    }
199}
200
201struct SubagentBackgroundTask {
202    task_id: String,
203    prompt: String,
204    description: Option<String>,
205    elapsed_ms: std::sync::Mutex<u64>,
206    state: std::sync::Mutex<SubagentBgState>,
207    started_at: Instant,
208    result_rx: tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<String>>>,
209    cancel_token: CancelToken,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213enum SubagentBgStatus {
214    Running,
215    Done,
216    Failed,
217    Cancelled,
218}
219
220#[derive(Debug, Clone)]
221struct SubagentBgState {
222    status: SubagentBgStatus,
223    error: String,
224}
225
226impl SubagentBgState {
227    fn running() -> Self {
228        Self {
229            status: SubagentBgStatus::Running,
230            error: String::new(),
231        }
232    }
233
234    fn done() -> Self {
235        Self {
236            status: SubagentBgStatus::Done,
237            error: String::new(),
238        }
239    }
240
241    fn failed(err: String) -> Self {
242        Self {
243            status: SubagentBgStatus::Failed,
244            error: err,
245        }
246    }
247
248    fn cancelled() -> Self {
249        Self {
250            status: SubagentBgStatus::Cancelled,
251            error: String::new(),
252        }
253    }
254
255    fn is_final(&self) -> bool {
256        matches!(
257            self.status,
258            SubagentBgStatus::Done | SubagentBgStatus::Failed | SubagentBgStatus::Cancelled
259        )
260    }
261}
262
263impl SubagentBackgroundTask {
264    async fn observation_json(&self) -> serde_json::Value {
265        let state = self.state.lock().unwrap_or_else(|e| e.into_inner()).clone();
266        let elapsed = self.elapsed_ms.lock().unwrap_or_else(|e| e.into_inner());
267        let mut value = json!({
268            "task_id": self.task_id,
269            "prompt": self.prompt,
270            "description": self.description,
271            "background": true,
272            "status": match state.status {
273                SubagentBgStatus::Running => "running",
274                SubagentBgStatus::Done => "done",
275                SubagentBgStatus::Failed => "failed",
276                SubagentBgStatus::Cancelled => "cancelled",
277            },
278            "elapsed_ms": *elapsed,
279        });
280        if !state.error.is_empty() {
281            value["error"] = json!(state.error);
282        }
283        if !state.is_final() {
284            value["message"] = json!(format!(
285                "Subagent is still running. Poll with subagent({{\"task_id\":\"{}\"}}) or cancel with subagent({{\"task_id\":\"{}\",\"action\":\"cancel\"}}).",
286                self.task_id, self.task_id
287            ));
288        }
289        value
290    }
291
292    fn try_read_result(&self) -> Option<String> {
293        let mut rx_guard = self.result_rx.try_lock().ok()?;
294        let rx = rx_guard.as_mut()?;
295        match rx.try_recv() {
296            Ok(result) => {
297                let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
298                *state = SubagentBgState::done();
299                *rx_guard = None;
300                Some(result)
301            }
302            Err(tokio::sync::oneshot::error::TryRecvError::Empty) => None,
303            Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
304                let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
305                if state.status == SubagentBgStatus::Running {
306                    *state = SubagentBgState::failed("subagent task dropped unexpectedly".into());
307                }
308                *rx_guard = None;
309                None
310            }
311        }
312    }
313}
314
315#[async_trait]
316impl Tool for SubagentTool {
317    fn definition(&self) -> ToolDefinition {
318        helpers::definition(
319            "subagent",
320            "Spawn an isolated subagent to autonomously perform a task. \
321             The subagent has full access to all tools (bash, read_file, write_file, grep, etc.) \
322             and makes its own decisions in a fresh conversation context. \
323             Use `background: true` to run asynchronously — the tool returns immediately \
324             with a task_id; poll with `{task_id}` or cancel with `{task_id, action: \"cancel\"}`.",
325            ToolKind::Read,
326            json!({
327                "type": "object",
328                "properties": {
329                    "prompt": {
330                        "type": "string",
331                        "description": "The task description for the subagent. Use this when starting a new subagent."
332                    },
333                    "description": {
334                        "type": "string",
335                        "description": "Additional context or constraints for the subagent (optional)."
336                    },
337                    "profile": {
338                        "type": "string",
339                        "enum": ["cheap_general", "cheap_code", "repo_search", "naming", "long_context_cheap", "research_synthesis"],
340                        "description": "Model profile to use for this subagent. Selects a cheaper model appropriate for the task type. Omit to use the main agent's model."
341                    },
342                    "options": {
343                        "type": "object",
344                        "description": "Subagent behavior options: agent profile, model override, tool restrictions, approval mode, and optional workflow write-path scope.",
345                        "properties": {
346                            "agent_profile": {
347                                "type": "string",
348                                "enum": ["planner", "explorer", "implementer", "reviewer", "security_reviewer", "verifier", "summarizer"],
349                                "description": "Agent role profile that sets default tool access and approval behavior. Planner/Explorer/Reviewer/SecurityReviewer/Verifier/Summarizer default to read-only; Implementer has full access."
350                            },
351                            "model": {
352                                "type": "string",
353                                "description": "Override the model used by this subagent."
354                            },
355                            "tools": {
356                                "type": "array",
357                                "items": { "type": "string" },
358                                "description": "Explicit list of tool names the subagent may call. When not set, all tools are available (subject to profile defaults)."
359                            },
360                            "approval": {
361                                "type": "string",
362                                "enum": ["inherit", "escalate", "read_only", "deny_write"],
363                                "description": "How tool approvals are handled. Inherit: use parent session's policy. Escalate: route approval requests to the parent session/user. ReadOnly: deny all write/command tools. DenyWrite: deny write tools but allow commands."
364                            },
365                            "max_tokens": {
366                                "type": "integer",
367                                "description": "Maximum tokens for the subagent's response."
368                            },
369                            "write_allow": {
370                                "type": "array",
371                                "items": { "type": "string" },
372                                "description": "Workflow write-path allowlist (relative paths). When set, forks a WritePathScope so only these paths may be written."
373                            },
374                            "path_deny": {
375                                "type": "array",
376                                "items": { "type": "string" },
377                                "description": "Workflow path deny list (relative paths). Always wins over write_allow."
378                            },
379                            "create_files": {
380                                "type": "boolean",
381                                "description": "When true (with write_allow), allow creating new files under the write scope. Default false for workflow workers."
382                            },
383                            "create_dirs": {
384                                "type": "boolean",
385                                "description": "When true (with write_allow), allow creating directories under the write scope. Default false for workflow workers."
386                            }
387                        },
388                        "additionalProperties": false
389                    },
390                    "background": {
391                        "type": "boolean",
392                        "description": "When true, spawn the subagent in the background and return a task_id. Poll or cancel later."
393                    },
394                    "task_id": {
395                        "type": "string",
396                        "description": "Background task id returned by an earlier subagent call."
397                    },
398                    "action": {
399                        "type": "string",
400                        "enum": ["poll", "cancel", "list"],
401                        "description": "Use poll/cancel with task_id, or list to show background subagents."
402                    }
403                },
404                "anyOf": [
405                    { "required": ["prompt"] },
406                    { "required": ["task_id"] },
407                    { "properties": { "action": { "const": "list" } }, "required": ["action"] }
408                ],
409                "additionalProperties": false,
410            }),
411        )
412    }
413
414    async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolResult> {
415        self.invoke_with_context(invocation, ToolInvocationContext::default())
416            .await
417    }
418
419    async fn invoke_with_context(
420        &self,
421        invocation: ToolInvocation,
422        context: ToolInvocationContext,
423    ) -> Result<ToolResult> {
424        if let Some(task_id) = helpers::optional_string(&invocation.input, "task_id") {
425            let action = helpers::optional_string(&invocation.input, "action")
426                .unwrap_or_else(|| "poll".to_string());
427            return self
428                .handle_background_action(invocation.id, &task_id, &action)
429                .await;
430        }
431
432        if helpers::optional_string(&invocation.input, "action").as_deref() == Some("list") {
433            return self.list_background_tasks(invocation.id).await;
434        }
435
436        let is_background =
437            helpers::optional_bool(&invocation.input, "background").unwrap_or(false);
438        let prompt = helpers::required_string(&invocation.input, "prompt")?.to_string();
439        let description = helpers::optional_string(&invocation.input, "description");
440        let profile = helpers::optional_string(&invocation.input, "profile");
441        let options = parse_subagent_options(&invocation.input);
442
443        if is_background {
444            return self
445                .spawn_background(
446                    invocation.id,
447                    prompt,
448                    description,
449                    profile,
450                    options,
451                    context.event_tx,
452                    context.cancel_token,
453                )
454                .await;
455        }
456
457        self.run_foreground(
458            invocation.id,
459            prompt,
460            description,
461            profile,
462            options,
463            context.event_tx,
464            context.cancel_token,
465        )
466        .await
467    }
468}
469
470impl SubagentTool {
471    async fn run_foreground(
472        &self,
473        invocation_id: String,
474        prompt: String,
475        description: Option<String>,
476        profile: Option<String>,
477        options: SubagentOptions,
478        parent_event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
479        parent_cancel: Option<CancelToken>,
480    ) -> Result<ToolResult> {
481        let executor = self
482            .tool_executor
483            .upgrade()
484            .context("subagent tool executor has been dropped")?;
485        let started = Instant::now();
486
487        // Resolve model provider based on profile.
488        let (provider, model) = self.resolve_model_for_profile(profile.as_deref());
489
490        // Determine if this subagent should be read-only based on agent profile.
491        let effective_approval = resolve_approval_mode(&options);
492        let allowed_tool_names =
493            resolve_allowed_tool_names(&executor, &options, effective_approval);
494
495        // When workflow write scope is present, fork a worker executor with
496        // WritePathScope so write_allow / path_deny / create_files are enforced
497        // by SecurityPolicy on every write tool call (not just prompt text).
498        let tool_executor: Arc<crate::tool::ToolExecutor> =
499            if let Some(scope) = write_scope_from_options(&options) {
500                let mut policy = executor.policy().clone();
501                policy = policy.with_write_scope(scope);
502                let names = allowed_tool_names
503                    .clone()
504                    .unwrap_or_else(|| executor.tool_names());
505                Arc::new(executor.fork_with_policy_and_tools(policy, &names))
506            } else {
507                executor
508            };
509
510        let (mut messages, event_tx, _approval_handle, resolver) = self.prepare_subagent_context(
511            &invocation_id,
512            &prompt,
513            &description,
514            effective_approval,
515            parent_event_tx.clone(),
516        );
517
518        let include_tool_prompt = self.include_tool_prompt_manifest();
519        let session_id = subagent_session_id();
520        // Freeze the specialized subagent system prompt. `run_turn` always
521        // calls `ensure_system_prompt`, which would otherwise rebuild the full
522        // parent-agent identity and erase explorer/verifier instructions.
523        let (instructions, prompt_prefix) = freeze_specialized_prompt(&messages);
524
525        // Prefer parent cancel (workflow/tool cancel) so nested turns stop.
526        let cancel_token = parent_cancel.unwrap_or_else(CancelToken::new);
527
528        let sub_ctx = TurnContext {
529            model_provider: Arc::new(RwLock::new(provider)),
530            tool_executor,
531            project_dir: self.project_dir.clone(),
532            data_dir: self.data_dir.clone(),
533            model_name: Arc::new(RwLock::new(model)),
534            event_tx: Some(event_tx),
535            approval_resolver: resolver,
536            question_resolver: crate::runtime::QuestionResolver::new_standalone(),
537            plan_review_resolver: crate::runtime::PlanReviewResolver::new_standalone(),
538            sudo_password_resolver: crate::runtime::SudoPasswordResolver::new_standalone(),
539            compact_state: Arc::new(tokio::sync::Mutex::new(CompactState::new(
540                crate::config::effective_context_window(
541                    &self.config.read().unwrap_or_else(|e| e.into_inner()),
542                ),
543            ))),
544            harness_config: self.harness_config.clone(),
545            include_tool_prompt_manifest: include_tool_prompt,
546            context_packets: Arc::new(std::sync::Mutex::new(Vec::new())),
547            available_skills: Arc::new(std::sync::Mutex::new(Vec::new())),
548            skill_pools: Arc::new(std::sync::Mutex::new(Vec::new())),
549            active_skills: Arc::new(std::sync::Mutex::new(Vec::new())),
550            // Fresh cache: do not share parent session prefix-cache keys.
551            prompt_cache: Arc::new(PromptCache::new()),
552            instructions,
553            prompt_prefix,
554            components: self.components.clone(),
555            cancel_token,
556            config: self.config.clone(),
557            memory_injection: None,
558            compaction_provider: None,
559            agent_mode: crate::plan_mode::AgentMode::Default,
560            compaction_model_name: None,
561            session_id,
562            allowed_tool_names,
563            is_subagent: true,
564            memory_manager: Arc::new(std::sync::Mutex::new(None)),
565            harness_card: None,
566        };
567
568        let policy =
569            crate::harness::policy_for_profile(&self.harness_config, self.harness_config.profile);
570
571        let result = crate::turn::run_turn(&sub_ctx, &mut messages, policy).await;
572        let elapsed = started.elapsed();
573
574        let text = match result {
575            Ok(output) => output,
576            Err(err) => format!("Subagent failed: {err:#}"),
577        };
578        emit_subagent_transcript(
579            &parent_event_tx,
580            &invocation_id,
581            SubagentTranscriptItem {
582                kind: SubagentTranscriptKind::Text,
583                title: "Final response".to_string(),
584                detail: Some(one_line(&text)),
585                ok: Some(!text.starts_with("Subagent failed:")),
586            },
587        );
588
589        Ok(helpers::ok(
590            invocation_id,
591            json!({
592                "result": text,
593                "elapsed_ms": elapsed.as_millis() as u64,
594            }),
595        ))
596    }
597
598    async fn spawn_background(
599        &self,
600        invocation_id: String,
601        prompt: String,
602        description: Option<String>,
603        profile: Option<String>,
604        options: SubagentOptions,
605        parent_event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
606        parent_cancel: Option<CancelToken>,
607    ) -> Result<ToolResult> {
608        let executor = match self.tool_executor.upgrade() {
609            Some(ex) => ex,
610            None => {
611                return Ok(helpers::ok(
612                    invocation_id,
613                    json!({"error": "tool executor unavailable"}),
614                ));
615            }
616        };
617
618        let mut tasks = self.background_tasks.lock().await;
619        let running = tasks
620            .values()
621            .filter(|t| !t.state.lock().unwrap_or_else(|e| e.into_inner()).is_final())
622            .count();
623        if running >= MAX_BACKGROUND_SUBAGENTS {
624            return Ok(helpers::ok(
625                invocation_id,
626                json!({
627                    "error": format!(
628                        "too many background subagents running (max {MAX_BACKGROUND_SUBAGENTS})"
629                    )
630                }),
631            ));
632        }
633
634        let task_id = format!("bg_{}", self.next_task_id.fetch_add(1, Ordering::SeqCst));
635        let (result_tx, result_rx) = tokio::sync::oneshot::channel::<String>();
636        let started = Instant::now();
637
638        // Link parent cancel into the background task token when provided.
639        let task_cancel = parent_cancel.unwrap_or_else(CancelToken::new);
640        let task = Arc::new(SubagentBackgroundTask {
641            task_id: task_id.clone(),
642            prompt: prompt.clone(),
643            description: description.clone(),
644            elapsed_ms: std::sync::Mutex::new(0),
645            state: std::sync::Mutex::new(SubagentBgState::running()),
646            started_at: started,
647            result_rx: tokio::sync::Mutex::new(Some(result_rx)),
648            cancel_token: task_cancel,
649        });
650        tasks.insert(task_id.clone(), task.clone());
651
652        // Resolve model provider based on profile.
653        let (resolved_provider, resolved_model) =
654            self.resolve_model_for_profile(profile.as_deref());
655        let model_provider = Arc::new(RwLock::new(resolved_provider));
656        let model_name = Arc::new(RwLock::new(resolved_model));
657        let components = self.components.clone();
658        let harness_config = self.harness_config.clone();
659        let config = self.config.clone();
660        let project_dir = self.project_dir.clone();
661        let data_dir = self.data_dir.clone();
662        let cancel_token = task.cancel_token.clone();
663        let parent_invocation_id = invocation_id.clone();
664        let session_id = subagent_session_id();
665
666        let effective_approval = resolve_approval_mode(&options);
667        let allowed_tool_names_clone =
668            resolve_allowed_tool_names(&executor, &options, effective_approval);
669
670        let tool_executor: Arc<crate::tool::ToolExecutor> =
671            if let Some(scope) = write_scope_from_options(&options) {
672                let mut policy = executor.policy().clone();
673                policy = policy.with_write_scope(scope);
674                let names = allowed_tool_names_clone
675                    .clone()
676                    .unwrap_or_else(|| executor.tool_names());
677                Arc::new(executor.fork_with_policy_and_tools(policy, &names))
678            } else {
679                executor
680            };
681
682        tokio::spawn(async move {
683            let (mut messages, event_tx, _approval_handle, resolver) =
684                Self::build_subagent_context_static(
685                    &parent_invocation_id,
686                    &prompt,
687                    &description,
688                    effective_approval,
689                    parent_event_tx.clone(),
690                );
691
692            let config_snapshot = config.read().unwrap_or_else(|e| e.into_inner()).clone();
693            let (instructions, prompt_prefix) = freeze_specialized_prompt(&messages);
694
695            let sub_ctx = TurnContext {
696                model_provider,
697                tool_executor,
698                project_dir,
699                data_dir,
700                model_name,
701                event_tx: Some(event_tx),
702                approval_resolver: resolver,
703                question_resolver: crate::runtime::QuestionResolver::new_standalone(),
704                plan_review_resolver: crate::runtime::PlanReviewResolver::new_standalone(),
705                sudo_password_resolver: crate::runtime::SudoPasswordResolver::new_standalone(),
706                compact_state: Arc::new(tokio::sync::Mutex::new(CompactState::new(
707                    crate::config::effective_context_window(&config_snapshot),
708                ))),
709                harness_config: harness_config.clone(),
710                include_tool_prompt_manifest: crate::config::effective_tool_prompt_manifest(
711                    &config_snapshot,
712                ),
713                context_packets: Arc::new(std::sync::Mutex::new(Vec::new())),
714                available_skills: Arc::new(std::sync::Mutex::new(Vec::new())),
715                skill_pools: Arc::new(std::sync::Mutex::new(Vec::new())),
716                active_skills: Arc::new(std::sync::Mutex::new(Vec::new())),
717                prompt_cache: Arc::new(PromptCache::new()),
718                instructions,
719                prompt_prefix,
720                components,
721                cancel_token,
722                config: Arc::new(std::sync::RwLock::new(config_snapshot)),
723                memory_injection: None,
724                compaction_provider: None,
725                compaction_model_name: None,
726                session_id,
727                agent_mode: crate::plan_mode::AgentMode::Default,
728                allowed_tool_names: allowed_tool_names_clone,
729                is_subagent: true,
730                memory_manager: Arc::new(std::sync::Mutex::new(None)),
731                harness_card: None,
732            };
733
734            let policy =
735                crate::harness::policy_for_profile(&harness_config, harness_config.profile);
736
737            let result = crate::turn::run_turn(&sub_ctx, &mut messages, policy).await;
738            let output = match result {
739                Ok(output) => output,
740                Err(err) => format!("Background subagent failed: {err:#}"),
741            };
742            emit_subagent_transcript(
743                &parent_event_tx,
744                &parent_invocation_id,
745                SubagentTranscriptItem {
746                    kind: SubagentTranscriptKind::Text,
747                    title: "Final response".to_string(),
748                    detail: Some(one_line(&output)),
749                    ok: Some(!output.starts_with("Background subagent failed:")),
750                },
751            );
752            let _ = result_tx.send(output);
753        });
754
755        Ok(helpers::ok(
756            invocation_id,
757            json!({
758                "task_id": task_id,
759                "message": format!(
760                    "Subagent spawned in background. Poll with subagent({{\"task_id\":\"{task_id}\"}}) or cancel with subagent({{\"task_id\":\"{task_id}\",\"action\":\"cancel\"}})."
761                ),
762                "action": "poll",
763                "background": true,
764                "status": "running",
765                "elapsed_ms": started.elapsed().as_millis() as u64,
766            }),
767        ))
768    }
769
770    async fn handle_background_action(
771        &self,
772        invocation_id: String,
773        task_id: &str,
774        action: &str,
775    ) -> Result<ToolResult> {
776        let tasks = self.background_tasks.lock().await;
777        let Some(task) = tasks.get(task_id).cloned() else {
778            return Ok(helpers::ok(
779                invocation_id,
780                json!({ "error": format!("no background subagent found with task_id {task_id}") }),
781            ));
782        };
783        drop(tasks);
784
785        match action {
786            "poll" => {
787                let _ = task.try_read_result();
788                let obs = task.observation_json().await;
789                Ok(helpers::ok(invocation_id, obs))
790            }
791            "cancel" => {
792                task.cancel_token.cancel();
793                {
794                    let mut state = task.state.lock().unwrap_or_else(|e| e.into_inner());
795                    if !state.is_final() {
796                        *state = SubagentBgState::cancelled();
797                    }
798                }
799                let obs = task.observation_json().await;
800                Ok(helpers::ok(invocation_id, obs))
801            }
802            _ => Ok(helpers::ok(
803                invocation_id,
804                json!({ "error": format!("unknown action: {action}") }),
805            )),
806        }
807    }
808
809    async fn list_background_tasks(&self, invocation_id: String) -> Result<ToolResult> {
810        let tasks = self.background_tasks.lock().await;
811        let mut list = Vec::new();
812        for task in tasks.values() {
813            let _ = task.try_read_result();
814            let state = task.state.lock().unwrap_or_else(|e| e.into_inner()).clone();
815            *task.elapsed_ms.lock().unwrap_or_else(|e| e.into_inner()) =
816                task.started_at.elapsed().as_millis() as u64;
817            list.push(json!({
818                "task_id": task.task_id,
819                "prompt": task.prompt,
820                "status": match state.status {
821                    SubagentBgStatus::Running => "running",
822                    SubagentBgStatus::Done => "done",
823                    SubagentBgStatus::Failed => "failed",
824                    SubagentBgStatus::Cancelled => "cancelled",
825                },
826                "elapsed_ms": task.started_at.elapsed().as_millis() as u64,
827            }));
828        }
829        Ok(helpers::ok(invocation_id, json!({ "tasks": list })))
830    }
831
832    fn include_tool_prompt_manifest(&self) -> bool {
833        crate::config::effective_tool_prompt_manifest(
834            &self.config.read().unwrap_or_else(|e| e.into_inner()),
835        )
836    }
837
838    /// Resolves a model provider and name for the given profile. Falls back to
839    /// the main agent's model when no profile is specified or resolution fails.
840    fn resolve_model_for_profile(&self, profile: Option<&str>) -> (Arc<dyn ModelProvider>, String) {
841        let Some(profile) = profile else {
842            return self.main_model();
843        };
844
845        let Some(ref resolver) = self.background_resolver else {
846            return self.main_model();
847        };
848
849        let Some(ref builder) = self.provider_builder else {
850            return self.main_model();
851        };
852
853        let resolved = resolver.resolve(profile);
854
855        // Build a provider for the resolved model.
856        let config_snapshot = self
857            .config
858            .read()
859            .unwrap_or_else(|e| e.into_inner())
860            .clone();
861        let mut bg_config = config_snapshot.clone();
862        bg_config.model.provider = resolved.provider_id.clone();
863        bg_config.model.name = resolved.model_name.clone();
864        let bg_loaded = LoadedConfig {
865            config: bg_config,
866            global_config_path: None,
867            project_config_path: None,
868            data_dir: self.data_dir.clone(),
869        };
870
871        match builder(&bg_loaded) {
872            Ok(provider) => (provider, resolved.model_name),
873            Err(_) => self.main_model(),
874        }
875    }
876
877    fn main_model(&self) -> (Arc<dyn ModelProvider>, String) {
878        (
879            self.model_provider
880                .read()
881                .unwrap_or_else(|e| e.into_inner())
882                .clone(),
883            self.model_name
884                .read()
885                .unwrap_or_else(|e| e.into_inner())
886                .clone(),
887        )
888    }
889
890    fn prepare_subagent_context(
891        &self,
892        parent_invocation_id: &str,
893        prompt: &str,
894        description: &Option<String>,
895        approval_mode: ApprovalMode,
896        parent_event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
897    ) -> (
898        Vec<ModelMessage>,
899        tokio::sync::mpsc::UnboundedSender<AgentEvent>,
900        tokio::task::JoinHandle<()>,
901        ApprovalResolver,
902    ) {
903        Self::build_subagent_context_static(
904            parent_invocation_id,
905            prompt,
906            description,
907            approval_mode,
908            parent_event_tx,
909        )
910    }
911
912    fn build_subagent_context_static(
913        parent_invocation_id: &str,
914        prompt: &str,
915        description: &Option<String>,
916        approval_mode: ApprovalMode,
917        parent_event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
918    ) -> (
919        Vec<ModelMessage>,
920        tokio::sync::mpsc::UnboundedSender<AgentEvent>,
921        tokio::task::JoinHandle<()>,
922        ApprovalResolver,
923    ) {
924        let access_note = match approval_mode {
925            ApprovalMode::ReadOnly => {
926                "Your tool access is read-only. Inspect, reason, and report findings; do not attempt writes or command execution."
927            }
928            ApprovalMode::DenyWrite => {
929                "Write tools are unavailable. You may inspect and run allowed verification commands when needed, then report findings."
930            }
931            ApprovalMode::Escalate => {
932                "Any risky action must be escalated to the parent session approval flow."
933            }
934            ApprovalMode::Inherit => "Use tools according to the parent session policy.",
935        };
936        let workflow = "\
937Workflow:\n\
9381. Inspect with the cheapest tools first (overview/search → targeted read).\n\
9392. Prefer project-relative paths; batch independent read-only calls when possible.\n\
9403. Keep edits narrow; verify with the smallest relevant command when writes are allowed.\n\
9414. If a tool fails, adapt once using the error — do not thrash the same call.\n\
9425. Observation budget: tool outputs may be truncated; request ranges/results explicitly.\n\
9436. When done, report paths, key diffs, and findings — not walls of file contents.";
944        let system = if let Some(desc) = description {
945            format!(
946                "You are a subagent worker for NAVI. Execute the assigned task autonomously \
947                 within your assigned access policy. {access_note}\n\n\
948                 Context: {desc}\n\n{workflow}\n\n\
949                 Be concise and deliver the result."
950            )
951        } else {
952            format!(
953                "You are a subagent worker for NAVI. Execute the assigned task autonomously \
954                 within your assigned access policy. {access_note}\n\n{workflow}\n\n\
955                 Be concise and deliver the result."
956            )
957        };
958
959        let messages = vec![
960            ModelMessage {
961                role: ModelRole::System,
962                content: system,
963                content_parts: Vec::new(),
964                tool_call_id: None,
965                tool_name: None,
966                tool_calls: vec![],
967                created_at: None,
968                thinking_content: None,
969            },
970            ModelMessage {
971                role: ModelRole::User,
972                content: prompt.to_string(),
973                content_parts: Vec::new(),
974                tool_call_id: None,
975                tool_name: None,
976                tool_calls: vec![],
977                created_at: None,
978                thinking_content: None,
979            },
980        ];
981
982        let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>();
983        let resolver = ApprovalResolver::new_standalone();
984        let resolver_bg = resolver.clone();
985        let parent_invocation_id = parent_invocation_id.to_string();
986        let is_escalate = approval_mode == ApprovalMode::Escalate;
987
988        let approval_handle = tokio::spawn(async move {
989            while let Some(event) = event_rx.recv().await {
990                if let Some(message) = subagent_activity_message(&event)
991                    && let Some(tx) = &parent_event_tx
992                {
993                    let _ = tx.send(AgentEvent::SubagentActivity {
994                        invocation_id: parent_invocation_id.clone(),
995                        message,
996                    });
997                }
998                if let Some(item) = subagent_transcript_item(&event) {
999                    emit_subagent_transcript(&parent_event_tx, &parent_invocation_id, item);
1000                }
1001                if let AgentEvent::ApprovalRequested(req) = event {
1002                    if is_escalate {
1003                        // Forward the approval request to the parent session.
1004                        // The parent's approval resolver will handle the response.
1005                        // We register on a standalone resolver and wait for the
1006                        // parent to resolve through the event channel.
1007                        if let Some(tx) = &parent_event_tx {
1008                            let _ = tx.send(AgentEvent::ApprovalRequested(
1009                                crate::event::ApprovalRequest {
1010                                    id: req.id.clone(),
1011                                    summary: req.summary.clone(),
1012                                    risk: req.risk.clone(),
1013                                },
1014                            ));
1015                        }
1016                        // In Escalate mode, we auto-approve locally since the
1017                        // parent handles the actual approval flow externally.
1018                        resolver_bg.resolve(ApprovalDecision::Approved { id: req.id.clone() });
1019                    } else {
1020                        resolver_bg.resolve(ApprovalDecision::Approved { id: req.id.clone() });
1021                    }
1022                }
1023            }
1024        });
1025
1026        (messages, event_tx, approval_handle, resolver)
1027    }
1028}
1029
1030fn emit_subagent_transcript(
1031    parent_event_tx: &Option<mpsc::UnboundedSender<AgentEvent>>,
1032    invocation_id: &str,
1033    item: SubagentTranscriptItem,
1034) {
1035    if let Some(tx) = parent_event_tx {
1036        let _ = tx.send(AgentEvent::SubagentTranscript {
1037            invocation_id: invocation_id.to_string(),
1038            item,
1039        });
1040    }
1041}
1042
1043fn subagent_activity_message(event: &AgentEvent) -> Option<String> {
1044    match event {
1045        AgentEvent::ToolRequested(invocation) => Some(format_tool_activity(invocation)),
1046        AgentEvent::ToolCompleted(result) if !result.ok => Some(format!(
1047            "{} failed",
1048            result
1049                .output
1050                .get("tool")
1051                .and_then(|value| value.as_str())
1052                .unwrap_or("Tool")
1053        )),
1054        _ => None,
1055    }
1056}
1057
1058fn subagent_transcript_item(event: &AgentEvent) -> Option<SubagentTranscriptItem> {
1059    match event {
1060        AgentEvent::ToolRequested(invocation) => Some(SubagentTranscriptItem {
1061            kind: SubagentTranscriptKind::ToolRequested,
1062            title: format_tool_activity(invocation),
1063            detail: None,
1064            ok: None,
1065        }),
1066        AgentEvent::ToolCompleted(result) => Some(SubagentTranscriptItem {
1067            kind: SubagentTranscriptKind::ToolCompleted,
1068            title: if result.ok {
1069                "Tool completed".to_string()
1070            } else {
1071                "Tool failed".to_string()
1072            },
1073            detail: Some(compact_result_detail(result)),
1074            ok: Some(result.ok),
1075        }),
1076        _ => None,
1077    }
1078}
1079
1080fn compact_result_detail(result: &ToolResult) -> String {
1081    if let Some(error) = result.output.get("error").and_then(|value| value.as_str()) {
1082        return one_line(error);
1083    }
1084    if let Some(path) = result.output.get("path").and_then(|value| value.as_str()) {
1085        return path.to_string();
1086    }
1087    if let Some(result_text) = result.output.get("result").and_then(|value| value.as_str()) {
1088        return one_line(result_text);
1089    }
1090    if result.output.is_null()
1091        || result
1092            .output
1093            .as_object()
1094            .is_some_and(serde_json::Map::is_empty)
1095    {
1096        return "ok".to_string();
1097    }
1098    serde_json::to_string(&result.output)
1099        .map(|value| one_line(&value))
1100        .unwrap_or_else(|_| "ok".to_string())
1101}
1102
1103fn format_tool_activity(invocation: &ToolInvocation) -> String {
1104    match invocation.tool_name.as_str() {
1105        "read_file" | "view_file" => format!("Read {}", input_path(invocation).unwrap_or("file")),
1106        "write_file" => format!("Write {}", input_path(invocation).unwrap_or("file")),
1107        "grep" => invocation
1108            .input
1109            .get("pattern")
1110            .and_then(|value| value.as_str())
1111            .map(|pattern| format!("Search \"{}\"", one_line(pattern)))
1112            .unwrap_or_else(|| "Search".to_string()),
1113        "fs_browser" => {
1114            let action = invocation
1115                .input
1116                .get("action")
1117                .and_then(|value| value.as_str())
1118                .unwrap_or("browse");
1119            format!(
1120                "{} {}",
1121                capitalize(action),
1122                input_path(invocation).unwrap_or("filesystem")
1123            )
1124        }
1125        "bash" => invocation
1126            .input
1127            .get("command")
1128            .or_else(|| invocation.input.get("program"))
1129            .and_then(|value| value.as_str())
1130            .map(|command| format!("Run {}", one_line(command)))
1131            .unwrap_or_else(|| "Run command".to_string()),
1132        "apply_patch" => "Apply patch".to_string(),
1133        "subagent" => invocation
1134            .input
1135            .get("description")
1136            .or_else(|| invocation.input.get("prompt"))
1137            .and_then(|value| value.as_str())
1138            .map(|task| format!("Subagent {}", one_line(task)))
1139            .unwrap_or_else(|| "Subagent task".to_string()),
1140        name => capitalize(&name.replace('_', " ")),
1141    }
1142}
1143
1144fn input_path(invocation: &ToolInvocation) -> Option<&str> {
1145    invocation
1146        .input
1147        .get("path")
1148        .or_else(|| invocation.input.get("file"))
1149        .or_else(|| invocation.input.get("target"))
1150        .and_then(|value| value.as_str())
1151}
1152
1153fn one_line(value: &str) -> String {
1154    value.split_whitespace().collect::<Vec<_>>().join(" ")
1155}
1156
1157fn capitalize(value: &str) -> String {
1158    let mut chars = value.chars().collect::<Vec<_>>();
1159    if let Some(first) = chars.first_mut() {
1160        first.make_ascii_uppercase();
1161    }
1162    chars.into_iter().collect()
1163}
1164
1165/// Parse `SubagentOptions` from the `"options"` field of a tool invocation input.
1166fn parse_subagent_options(input: &Value) -> SubagentOptions {
1167    let Some(options_value) = input.get("options") else {
1168        return SubagentOptions::default();
1169    };
1170    serde_json::from_value(options_value.clone()).unwrap_or_default()
1171}
1172
1173impl Default for SubagentOptions {
1174    fn default() -> Self {
1175        Self {
1176            profile: None,
1177            model: None,
1178            tools: None,
1179            approval: ApprovalMode::Inherit,
1180            max_tokens: None,
1181            write_allow: None,
1182            path_deny: None,
1183            create_files: None,
1184            create_dirs: None,
1185        }
1186    }
1187}
1188
1189fn write_scope_from_options(options: &SubagentOptions) -> Option<crate::security::WritePathScope> {
1190    // Only install a write scope when the caller explicitly set workflow fields.
1191    if options.write_allow.is_none()
1192        && options.path_deny.is_none()
1193        && options.create_files.is_none()
1194        && options.create_dirs.is_none()
1195    {
1196        return None;
1197    }
1198    Some(crate::security::WritePathScope {
1199        write_allow: options.write_allow.clone().unwrap_or_default(),
1200        path_deny: options.path_deny.clone().unwrap_or_default(),
1201        create_files: options.create_files.unwrap_or(false),
1202        create_dirs: options.create_dirs.unwrap_or(false),
1203    })
1204}
1205
1206/// Resolves the effective approval mode from a profile preference cascade.
1207/// An explicit `options.approval` wins; otherwise `options.profile` determines
1208/// the mode: Explorer/Reviewer/Verifier/Summarizer default to ReadOnly;
1209/// Implementer defaults to Inherit.
1210fn resolve_approval_mode(options: &SubagentOptions) -> ApprovalMode {
1211    if options.approval != ApprovalMode::Inherit {
1212        return options.approval;
1213    }
1214    match options.profile {
1215        Some(AgentProfile::Planner)
1216        | Some(AgentProfile::Explorer)
1217        | Some(AgentProfile::Reviewer)
1218        | Some(AgentProfile::SecurityReviewer)
1219        | Some(AgentProfile::Verifier)
1220        | Some(AgentProfile::Summarizer) => ApprovalMode::ReadOnly,
1221        Some(AgentProfile::Implementer) | None => ApprovalMode::Inherit,
1222    }
1223}
1224
1225/// Freeze specialized system/developer messages so `ensure_system_prompt`
1226/// reuses them instead of rebuilding the full parent-agent prompt.
1227fn freeze_specialized_prompt(
1228    messages: &[ModelMessage],
1229) -> (
1230    Arc<RwLock<Option<String>>>,
1231    Arc<std::sync::Mutex<Option<Vec<ModelMessage>>>>,
1232) {
1233    let prefix: Vec<ModelMessage> = messages
1234        .iter()
1235        .take_while(|m| matches!(m.role, ModelRole::System | ModelRole::Developer))
1236        .cloned()
1237        .collect();
1238    let instructions = prefix
1239        .iter()
1240        .find(|m| m.role == ModelRole::System)
1241        .map(|m| m.content.clone());
1242    (
1243        Arc::new(RwLock::new(instructions)),
1244        Arc::new(std::sync::Mutex::new(Some(prefix))),
1245    )
1246}
1247
1248/// Returns the set of tool names allowed for this subagent.
1249///
1250/// Always strips nested agent tools to prevent recursive spawn storms.
1251/// ReadOnly/DenyWrite additionally strip write-oriented tools.
1252fn resolve_allowed_tool_names(
1253    executor: &crate::tool::ToolExecutor,
1254    options: &SubagentOptions,
1255    approval_mode: ApprovalMode,
1256) -> Option<Vec<String>> {
1257    let mut allowed = options
1258        .tools
1259        .clone()
1260        .unwrap_or_else(|| executor.tool_names());
1261    // Always block nested agent spawning (depth = 1).
1262    allowed.retain(|name| !NESTED_AGENT_TOOLS.contains(&name.as_str()));
1263    match approval_mode {
1264        ApprovalMode::ReadOnly => {
1265            allowed.retain(|name| !READONLY_DENIED_TOOLS.contains(&name.as_str()));
1266        }
1267        ApprovalMode::DenyWrite => {
1268            allowed.retain(|name| !WRITE_DENIED_TOOLS.contains(&name.as_str()));
1269        }
1270        ApprovalMode::Inherit | ApprovalMode::Escalate => {}
1271    }
1272    // Always return Some so nested agent tools stay filtered even for Inherit.
1273    Some(allowed)
1274}
1275
1276/// Each nested agent is an independent provider conversation. Reusing a
1277/// literal id (such as `subagent`) made Charm Hyper route unrelated agents to
1278/// the same affinity/cache bucket.
1279fn subagent_session_id() -> String {
1280    format!("subagent-{}", SessionStore::create_id().into_inner())
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use serde_json::json;
1287
1288    /// Serde roundtrip test for SubagentOptions.
1289    #[test]
1290    fn subagent_options_serde_roundtrip() {
1291        let opts = SubagentOptions {
1292            profile: Some(AgentProfile::Explorer),
1293            model: Some("gpt-4".to_string()),
1294            tools: Some(vec!["read".to_string(), "search".to_string()]),
1295            approval: ApprovalMode::ReadOnly,
1296            max_tokens: Some(4096),
1297            ..Default::default()
1298        };
1299        let json = serde_json::to_value(&opts).unwrap();
1300        let deserialized: SubagentOptions = serde_json::from_value(json).unwrap();
1301        assert_eq!(deserialized.profile, Some(AgentProfile::Explorer));
1302        assert_eq!(deserialized.model, Some("gpt-4".to_string()));
1303        assert_eq!(
1304            deserialized.tools,
1305            Some(vec!["read".to_string(), "search".to_string()])
1306        );
1307        assert_eq!(deserialized.approval, ApprovalMode::ReadOnly);
1308        assert_eq!(deserialized.max_tokens, Some(4096));
1309    }
1310
1311    #[test]
1312    fn subagent_options_default_is_inherit() {
1313        let opts = SubagentOptions::default();
1314        assert_eq!(opts.approval, ApprovalMode::Inherit);
1315        assert!(opts.profile.is_none());
1316        assert!(opts.model.is_none());
1317        assert!(opts.tools.is_none());
1318        assert!(opts.max_tokens.is_none());
1319    }
1320
1321    #[test]
1322    fn subagent_options_serde_missing_fields_default_correctly() {
1323        let json = json!({});
1324        let opts: SubagentOptions = serde_json::from_value(json).unwrap();
1325        assert_eq!(opts.approval, ApprovalMode::Inherit);
1326        assert!(opts.profile.is_none());
1327        assert!(opts.tools.is_none());
1328    }
1329
1330    #[test]
1331    fn subagent_options_serde_with_profile_only() {
1332        let json = json!({"agent_profile": "explorer"});
1333        let opts: SubagentOptions = serde_json::from_value(json).unwrap();
1334        assert_eq!(opts.profile, Some(AgentProfile::Explorer));
1335        assert_eq!(opts.approval, ApprovalMode::Inherit);
1336    }
1337
1338    #[test]
1339    fn subagent_options_serde_workflow_write_scope() {
1340        let json = json!({
1341            "agent_profile": "explorer",
1342            "tools": ["read_file", "search"],
1343            "approval": "read_only",
1344            "write_allow": [],
1345            "path_deny": ["secrets/"],
1346            "create_files": false,
1347            "create_dirs": false
1348        });
1349        let opts: SubagentOptions = serde_json::from_value(json).unwrap();
1350        assert_eq!(opts.profile, Some(AgentProfile::Explorer));
1351        assert_eq!(opts.write_allow.as_deref(), Some([].as_slice()));
1352        assert_eq!(
1353            opts.path_deny.as_deref(),
1354            Some(["secrets/".to_string()].as_slice())
1355        );
1356        assert_eq!(opts.create_files, Some(false));
1357        assert_eq!(opts.create_dirs, Some(false));
1358    }
1359
1360    #[test]
1361    fn schema_accepts_workflow_bridge_options() {
1362        // Mirrors SubagentBridgeBackend::run_agent options payload. Regression for:
1363        // "Additional properties are not allowed ('create_dirs', 'create_files', ...)"
1364        // Build a throwaway tool only for its schema (no model calls).
1365        struct NoopProvider;
1366        impl ModelProvider for NoopProvider {
1367            fn stream(&self, _req: crate::model::ModelRequest) -> crate::model::ModelStream {
1368                Box::pin(futures_util::stream::empty())
1369            }
1370        }
1371        let tool = SubagentTool::new(
1372            std::sync::Weak::new(),
1373            Arc::new(RwLock::new(Arc::new(NoopProvider) as Arc<dyn ModelProvider>)),
1374            std::path::PathBuf::from("/tmp"),
1375            std::path::PathBuf::from("/tmp"),
1376            Arc::new(RwLock::new("test".into())),
1377            HarnessConfig::default(),
1378            Arc::new(RwLock::new(NaviConfig::default())),
1379            Arc::new(PromptCache::new()),
1380            RuntimeComponents::default(),
1381        );
1382        let schema = tool.definition().input_schema;
1383        let validator = jsonschema::validator_for(&schema).expect("compile schema");
1384        let instance = json!({
1385            "prompt": "list files",
1386            "description": "collect",
1387            "options": {
1388                "agent_profile": "explorer",
1389                "tools": ["read_file", "search", "list_dir"],
1390                "approval": "read_only",
1391                "write_allow": [],
1392                "path_deny": [],
1393                "create_files": false,
1394                "create_dirs": false
1395            }
1396        });
1397        let errors: Vec<String> = validator
1398            .iter_errors(&instance)
1399            .map(|e| e.to_string())
1400            .collect();
1401        assert!(
1402            errors.is_empty(),
1403            "workflow bridge options must pass subagent schema: {errors:?}"
1404        );
1405    }
1406
1407    #[test]
1408    fn resolve_approval_mode_readonly_profiles() {
1409        for profile in &[
1410            AgentProfile::Explorer,
1411            AgentProfile::Reviewer,
1412            AgentProfile::Planner,
1413            AgentProfile::SecurityReviewer,
1414            AgentProfile::Verifier,
1415            AgentProfile::Summarizer,
1416        ] {
1417            let opts = SubagentOptions {
1418                profile: Some(*profile),
1419                ..Default::default()
1420            };
1421            assert_eq!(
1422                resolve_approval_mode(&opts),
1423                ApprovalMode::ReadOnly,
1424                "{:?} should default to ReadOnly",
1425                profile
1426            );
1427        }
1428    }
1429
1430    #[test]
1431    fn resolve_approval_mode_implementer_inherits() {
1432        let opts = SubagentOptions {
1433            profile: Some(AgentProfile::Implementer),
1434            ..Default::default()
1435        };
1436        assert_eq!(resolve_approval_mode(&opts), ApprovalMode::Inherit);
1437    }
1438
1439    #[test]
1440    fn resolve_approval_mode_explicit_wins() {
1441        let opts = SubagentOptions {
1442            profile: Some(AgentProfile::Implementer),
1443            approval: ApprovalMode::ReadOnly,
1444            ..Default::default()
1445        };
1446        assert_eq!(resolve_approval_mode(&opts), ApprovalMode::ReadOnly);
1447    }
1448
1449    #[test]
1450    fn resolve_approval_mode_no_profile_inherits() {
1451        let opts = SubagentOptions::default();
1452        assert_eq!(resolve_approval_mode(&opts), ApprovalMode::Inherit);
1453    }
1454
1455    /// ReadOnly mode should deny write tools via allowed_tool_names filtering.
1456    #[test]
1457    fn readonly_approval_mode_filteres_write_tools() {
1458        // Verify that the TurnContext's allowed_tool_names check was properly
1459        // set up. This test validates the setup logic, not the actual turn execution.
1460        let opts = SubagentOptions {
1461            approval: ApprovalMode::ReadOnly,
1462            ..Default::default()
1463        };
1464        let mode = resolve_approval_mode(&opts);
1465        assert_eq!(mode, ApprovalMode::ReadOnly);
1466        // The actual enforcement happens via allowed_tool_names in TurnContext.
1467        // ReadOnly mode sets allowed_tool_names to exclude write tools.
1468        // We verify the setup path exists.
1469    }
1470
1471    #[test]
1472    fn explicit_tool_allowlist_is_intersected_with_readonly_profile() {
1473        let temp = tempfile::tempdir().unwrap();
1474        let policy = crate::security::SecurityPolicy::new(
1475            temp.path().to_path_buf(),
1476            temp.path()
1477                .parent()
1478                .unwrap_or(temp.path())
1479                .join("navi-test-data-subagent"),
1480            crate::config::SecurityConfig::default(),
1481        )
1482        .unwrap();
1483        let executor = crate::tool::ToolExecutor::new(policy);
1484        let opts = SubagentOptions {
1485            profile: Some(AgentProfile::Reviewer),
1486            tools: Some(vec![
1487                "read".to_string(),
1488                "search".to_string(),
1489                "write_file".to_string(),
1490                "code_exec".to_string(),
1491            ]),
1492            ..Default::default()
1493        };
1494
1495        let allowed = resolve_allowed_tool_names(&executor, &opts, resolve_approval_mode(&opts))
1496            .expect("restricted tools");
1497
1498        assert!(allowed.contains(&"read".to_string()));
1499        assert!(allowed.contains(&"search".to_string()));
1500        assert!(!allowed.contains(&"write_file".to_string()));
1501        assert!(!allowed.contains(&"code_exec".to_string()));
1502    }
1503
1504    #[test]
1505    fn deny_write_keeps_command_tools_available_for_verification() {
1506        let temp = tempfile::tempdir().unwrap();
1507        let policy = crate::security::SecurityPolicy::new(
1508            temp.path().to_path_buf(),
1509            temp.path()
1510                .parent()
1511                .unwrap_or(temp.path())
1512                .join("navi-test-data-subagent"),
1513            crate::config::SecurityConfig::default(),
1514        )
1515        .unwrap();
1516        let executor = crate::tool::ToolExecutor::new(policy);
1517        let opts = SubagentOptions {
1518            approval: ApprovalMode::DenyWrite,
1519            tools: Some(vec![
1520                "read".to_string(),
1521                "bash".to_string(),
1522                "write_file".to_string(),
1523            ]),
1524            ..Default::default()
1525        };
1526
1527        let allowed = resolve_allowed_tool_names(&executor, &opts, ApprovalMode::DenyWrite)
1528            .expect("restricted tools");
1529
1530        assert!(allowed.contains(&"read".to_string()));
1531        assert!(allowed.contains(&"bash".to_string()));
1532        assert!(!allowed.contains(&"write_file".to_string()));
1533    }
1534
1535    #[test]
1536    fn nested_agent_tools_always_stripped_even_for_inherit() {
1537        let temp = tempfile::tempdir().unwrap();
1538        let policy = crate::security::SecurityPolicy::new(
1539            temp.path().to_path_buf(),
1540            temp.path()
1541                .parent()
1542                .unwrap_or(temp.path())
1543                .join("navi-test-data-subagent"),
1544            crate::config::SecurityConfig::default(),
1545        )
1546        .unwrap();
1547        let executor = crate::tool::ToolExecutor::new(policy);
1548        let opts = SubagentOptions {
1549            tools: Some(vec![
1550                "read_file".to_string(),
1551                "subagent".to_string(),
1552                "repo_explore".to_string(),
1553                "bash".to_string(),
1554            ]),
1555            ..Default::default()
1556        };
1557
1558        let allowed = resolve_allowed_tool_names(&executor, &opts, ApprovalMode::Inherit)
1559            .expect("always filtered");
1560
1561        assert!(allowed.contains(&"read_file".to_string()));
1562        assert!(allowed.contains(&"bash".to_string()));
1563        // repo_explore is BM25 (cheap) — allowed inside subagents.
1564        assert!(allowed.contains(&"repo_explore".to_string()));
1565        assert!(!allowed.contains(&"subagent".to_string()));
1566        assert!(!allowed.contains(&"branch_race".to_string()));
1567    }
1568
1569    #[test]
1570    fn freeze_specialized_prompt_keeps_system_instructions() {
1571        let messages = vec![
1572            ModelMessage {
1573                role: ModelRole::System,
1574                content: "You are a focused explorer.".into(),
1575                content_parts: Vec::new(),
1576                tool_call_id: None,
1577                tool_name: None,
1578                tool_calls: vec![],
1579                created_at: None,
1580                thinking_content: None,
1581            },
1582            ModelMessage {
1583                role: ModelRole::User,
1584                content: "Find the auth module.".into(),
1585                content_parts: Vec::new(),
1586                tool_call_id: None,
1587                tool_name: None,
1588                tool_calls: vec![],
1589                created_at: None,
1590                thinking_content: None,
1591            },
1592        ];
1593        let (instructions, prefix) = freeze_specialized_prompt(&messages);
1594        assert_eq!(
1595            instructions
1596                .read()
1597                .unwrap_or_else(|e| e.into_inner())
1598                .as_deref(),
1599            Some("You are a focused explorer.")
1600        );
1601        let frozen = prefix
1602            .lock()
1603            .unwrap_or_else(|e| e.into_inner())
1604            .clone()
1605            .expect("prefix");
1606        assert_eq!(frozen.len(), 1);
1607        assert_eq!(frozen[0].role, ModelRole::System);
1608        assert_eq!(frozen[0].content, "You are a focused explorer.");
1609    }
1610
1611    #[test]
1612    fn agent_profile_serde_roundtrip() {
1613        for profile in &[
1614            AgentProfile::Explorer,
1615            AgentProfile::Implementer,
1616            AgentProfile::Reviewer,
1617            AgentProfile::SecurityReviewer,
1618            AgentProfile::Verifier,
1619            AgentProfile::Planner,
1620            AgentProfile::Summarizer,
1621        ] {
1622            let json = serde_json::to_value(profile).unwrap();
1623            let deserialized: AgentProfile = serde_json::from_value(json).unwrap();
1624            assert_eq!(&deserialized, profile);
1625        }
1626    }
1627
1628    #[test]
1629    fn subagents_get_distinct_provider_session_ids() {
1630        let first = subagent_session_id();
1631        let second = subagent_session_id();
1632
1633        assert!(first.starts_with("subagent-session-"));
1634        assert_ne!(first, second);
1635    }
1636}