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