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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum AgentProfile {
32 Planner,
34 Explorer,
36 Implementer,
38 Reviewer,
40 SecurityReviewer,
42 Verifier,
44 Summarizer,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum ApprovalMode {
52 Inherit,
54 Escalate,
56 ReadOnly,
58 DenyWrite,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct SubagentOptions {
65 #[serde(
67 default,
68 skip_serializing_if = "Option::is_none",
69 rename = "agent_profile"
70 )]
71 pub profile: Option<AgentProfile>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub model: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub tools: Option<Vec<String>>,
78 #[serde(default)]
80 pub approval: ApprovalMode,
81 #[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;
93const NESTED_AGENT_TOOLS: &[&str] = &["subagent"];
96const 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
123pub 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 #[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 background_resolver: Option<Arc<BackgroundModelResolver>>,
143 data_dir: std::path::PathBuf,
145 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 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 let (provider, model) = self.resolve_model_for_profile(profile.as_deref());
459
460 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 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 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 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 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 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 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 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
1094fn 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
1114fn 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
1133fn 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
1156fn 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 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 Some(allowed)
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use serde_json::json;
1188
1189 #[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 #[test]
1288 fn readonly_approval_mode_filteres_write_tools() {
1289 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 }
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 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}