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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum AgentProfile {
33 Planner,
35 Explorer,
37 Implementer,
39 Reviewer,
41 SecurityReviewer,
43 Verifier,
45 Summarizer,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ApprovalMode {
53 Inherit,
55 Escalate,
57 ReadOnly,
59 DenyWrite,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct SubagentOptions {
66 #[serde(
68 default,
69 skip_serializing_if = "Option::is_none",
70 rename = "agent_profile"
71 )]
72 pub profile: Option<AgentProfile>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub model: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub tools: Option<Vec<String>>,
79 #[serde(default)]
81 pub approval: ApprovalMode,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub max_tokens: Option<usize>,
85 #[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;
103const NESTED_AGENT_TOOLS: &[&str] = &["subagent", "workflow"];
106const 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
133pub 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 _prompt_cache: Arc<PromptCache>,
147 components: RuntimeComponents,
148 background_tasks: tokio::sync::Mutex<HashMap<String, Arc<SubagentBackgroundTask>>>,
149 next_task_id: AtomicU64,
150 background_resolver: Option<Arc<BackgroundModelResolver>>,
152 data_dir: std::path::PathBuf,
154 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 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 let (provider, model) = self.resolve_model_for_profile(profile.as_deref());
489
490 let effective_approval = resolve_approval_mode(&options);
492 let allowed_tool_names =
493 resolve_allowed_tool_names(&executor, &options, effective_approval);
494
495 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 let (instructions, prompt_prefix) = freeze_specialized_prompt(&messages);
524
525 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 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 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 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 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 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 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 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
1165fn 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 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
1206fn 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
1225fn 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
1248fn 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 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 Some(allowed)
1274}
1275
1276fn 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 #[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 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 #[test]
1457 fn readonly_approval_mode_filteres_write_tools() {
1458 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 }
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 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}