1use std::sync::Arc;
2
3use async_trait::async_trait;
4
5use crate::agents::{
6 McpAccessPolicy, agent_spec, agent_specs, agent_specs_prompt, default_agent_spec_id,
7};
8use crate::app::domain::event::SessionEvent;
9use crate::app::domain::runtime::RuntimeService;
10use crate::app::domain::types::SessionId;
11use crate::app::validation::ValidatorRegistry;
12use crate::config::model::builtin::claude_sonnet_4_5 as default_model;
13use crate::runners::OneShotRunner;
14use crate::session::state::BackendConfig;
15use crate::tools::capability::Capabilities;
16use crate::tools::services::{SubAgentConfig, SubAgentError, ToolServices};
17use crate::tools::static_tool::{StaticTool, StaticToolContext, StaticToolError};
18use crate::tools::{BackendRegistry, ToolExecutor, ToolRegistry};
19use crate::workspace::{
20 CreateWorkspaceRequest, EnvironmentId, RepoRef, VcsKind, VcsStatus, Workspace,
21 WorkspaceCreateStrategy, WorkspaceRef, create_workspace_from_session_config,
22};
23use steer_tools::ToolSpec;
24use steer_tools::result::{AgentResult, AgentWorkspaceInfo, AgentWorkspaceRevision};
25use steer_tools::tools::dispatch_agent::{
26 DispatchAgentError, DispatchAgentParams, DispatchAgentTarget, DispatchAgentToolSpec,
27 WorkspaceTarget,
28};
29use steer_tools::tools::{GREP_TOOL_NAME, LS_TOOL_NAME, VIEW_TOOL_NAME};
30use tracing::warn;
31
32use super::{
33 AstGrepTool, BashTool, EditTool, FetchTool, GlobTool, GrepTool, LsTool, MultiEditTool,
34 ReplaceTool, TodoReadTool, TodoWriteTool, ViewTool, workspace_manager_op_error,
35 workspace_op_error,
36};
37
38fn dispatch_agent_description() -> String {
39 let agent_specs = agent_specs_prompt();
40 let agent_specs_block = if agent_specs.is_empty() {
41 "No agent specs registered.".to_string()
42 } else {
43 agent_specs
44 };
45
46 format!(
47 r#"Launch a new agent to help with a focused task. Delegate work to sub-agents when you want to keep your own context window focused, or when tasks can run in parallel.
48
49When to use this tool:
50- If you need to edit files for a focused task (a feature, bug fix, or refactor), dispatch a sub-agent with the task and all relevant context so your own context stays clean
51- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", dispatch a sub-agent to search
52- If a task can be split into independent subtasks, dispatch multiple sub-agents concurrently
53
54When NOT to use this tool:
55- If you want to read a specific file path, use the {} or {} tool instead, to find the match more quickly
56- If you are searching for a specific class definition like "class Foo", use the {} tool instead, to find the match more quickly
57- If you are searching for code within a specific file or set of 2-3 files, use the {} tool instead, to find the match more quickly
58- Don't dispatch a sub-agent for a one-line fix you can make directly
59
60How to write an effective sub-agent prompt:
611. Start with the goal and expected output format
622. Include concrete context you've already gathered (file paths, symbol names, error messages, constraints, and acceptance criteria) so the sub-agent does not need to re-gather it
633. Name exactly which files or directories to inspect first when known
644. State whether the sub-agent should only explore or is expected to edit/build/test
655. Do NOT include synthetic path headers like `Repo: <path>` or `CWD: <path>`; working-directory context is injected automatically
66
67Example of a strong sub-agent prompt:
68 "The login endpoint at `src/api/auth.rs:142` returns 401 for valid tokens because `validate_token` checks expiry with `>` instead of `>=`. Change the comparison to `>=` and verify the existing test in `tests/auth_test.rs` still passes."
69
70Compare with a weak prompt that forces the sub-agent to rediscover context:
71 "Fix the bug in auth"
72
73Usage:
741. Launch multiple agents concurrently whenever possible; use a single message with multiple tool uses
752. The result returned by the agent is not visible to the user. Summarize it for the user in a text message.
763. Use `location: "new"` when dispatching multiple sub-agents that may edit overlapping files, to avoid conflicts.
774. IMPORTANT: Only some agent specs include write tools. Use a build agent if the task requires editing files.
78
79Reference:
80- Each invocation returns a session_id. Pass it back via `target: {{ "session": "resume", "session_id": "<uuid>" }}` to continue the conversation with the same agent.
81- When `target.session` is `resume`, the session_id must refer to a child of the current session. The `agent` and `workspace` options are ignored and the existing session config is used.
82- The agent's outputs should generally be trusted.
83- New workspaces are preserved (not auto-deleted). Clean them up manually if needed.
84- If the agent spec omits a model, the parent session's default model is used.
85- If `target.session` is `new` and `workspace.location` is `new`, the sub-agent runs in the newly created workspace path, which may differ from the caller's current directory.
86
87Workspace options:
88- `workspace: {{ "location": "current" }}` to run in the current workspace
89- `workspace: {{ "location": "new", "name": "..." }}` to run in a fresh workspace (jj workspace or git worktree)
90- `location` is a logical workspace selector, not a filesystem path
91
92Session options:
93- `target: {{ "session": "resume", "session_id": "<uuid>" }}` to continue a prior dispatch_agent session
94
95New session options:
96- `target: {{ "session": "new", "workspace": {{ "location": "current" }} }}` to run in the current workspace
97- `target: {{ "session": "new", "workspace": {{ "location": "new", "name": "..." }} }}` to run in a new workspace
98- `target: {{ "session": "new", "workspace": {{ "location": "current" }}, "agent": "<id>" }}` selects an agent spec (defaults to "{default_agent}")
99
100{agent_specs_block}"#,
101 VIEW_TOOL_NAME,
102 LS_TOOL_NAME,
103 GREP_TOOL_NAME,
104 GREP_TOOL_NAME,
105 default_agent = default_agent_spec_id(),
106 agent_specs_block = agent_specs_block
107 )
108}
109
110pub struct DispatchAgentTool;
111
112#[async_trait]
113impl StaticTool for DispatchAgentTool {
114 type Params = DispatchAgentParams;
115 type Output = AgentResult;
116 type Spec = DispatchAgentToolSpec;
117
118 const DESCRIPTION: &'static str = "Launch a sub-agent with full context for focused search, implementation, or parallel subtasks";
119 const REQUIRES_APPROVAL: bool = false;
120 const REQUIRED_CAPABILITIES: Capabilities = Capabilities::AGENT;
121
122 fn schema() -> steer_tools::ToolSchema {
123 let settings = schemars::generate::SchemaSettings::draft07().with(|s| {
124 s.inline_subschemas = true;
125 });
126 let schema_gen = settings.into_generator();
127 let input_schema = schema_gen.into_root_schema_for::<Self::Params>();
128
129 steer_tools::ToolSchema {
130 name: Self::Spec::NAME.to_string(),
131 display_name: Self::Spec::DISPLAY_NAME.to_string(),
132 description: dispatch_agent_description(),
133 input_schema: input_schema.into(),
134 }
135 }
136
137 async fn execute(
138 &self,
139 params: Self::Params,
140 ctx: &StaticToolContext,
141 ) -> Result<Self::Output, StaticToolError<DispatchAgentError>> {
142 let DispatchAgentParams { prompt, target } = params;
143
144 let (workspace_target, agent) = match target {
145 DispatchAgentTarget::Resume { session_id } => {
146 let session_id = SessionId::parse(&session_id).ok_or_else(|| {
147 StaticToolError::invalid_params(format!("Invalid session_id '{session_id}'"))
148 })?;
149 return resume_agent_session(session_id, prompt, ctx).await;
150 }
151 DispatchAgentTarget::New { workspace, agent } => (workspace, agent),
152 };
153
154 let spawner = ctx
155 .services
156 .agent_spawner()
157 .ok_or_else(|| StaticToolError::missing_capability("agent_spawner"))?;
158
159 let base_workspace = ctx.services.workspace.clone();
160 let base_path = base_workspace.working_directory().to_path_buf();
161
162 let mut workspace = base_workspace.clone();
163 let mut workspace_ref = None;
164 let mut workspace_id = None;
165 let mut workspace_name = None;
166 let mut repo_id = None;
167 let mut repo_ref = None;
168
169 if let Some(manager) = ctx.services.workspace_manager()
170 && let Ok(info) = manager.resolve_workspace(&base_path).await
171 {
172 workspace_id = Some(info.workspace_id);
173 workspace_name.clone_from(&info.name);
174 repo_id = Some(info.repo_id);
175 workspace_ref = Some(WorkspaceRef {
176 environment_id: info.environment_id,
177 workspace_id: info.workspace_id,
178 repo_id: info.repo_id,
179 });
180 }
181
182 if let Some(manager) = ctx.services.repo_manager() {
183 let repo_env_id = workspace_ref
184 .as_ref()
185 .map_or_else(EnvironmentId::local, |reference| reference.environment_id);
186 if let Ok(info) = manager.resolve_repo(repo_env_id, &base_path).await {
187 if repo_id.is_none() {
188 repo_id = Some(info.repo_id);
189 }
190 repo_ref = Some(RepoRef {
191 environment_id: info.environment_id,
192 repo_id: info.repo_id,
193 root_path: info.root_path,
194 vcs_kind: info.vcs_kind,
195 });
196 }
197 }
198
199 let mut new_workspace = false;
200 let mut requested_workspace_name = None;
201
202 match &workspace_target {
203 WorkspaceTarget::Current => {}
204 WorkspaceTarget::New { name } => {
205 new_workspace = true;
206 requested_workspace_name = Some(name.clone());
207 }
208 }
209
210 let mut created_workspace_id = None;
211 let mut status_manager = None;
212
213 if new_workspace {
214 let manager = ctx
215 .services
216 .workspace_manager()
217 .ok_or_else(|| StaticToolError::missing_capability("workspace_manager"))?;
218 status_manager = Some(manager.clone());
219
220 let base_repo_id = repo_id.ok_or_else(|| {
221 StaticToolError::execution(DispatchAgentError::WorkspaceUnavailable {
222 message:
223 "Current path is not a supported workspace; cannot create new workspace"
224 .to_string(),
225 })
226 })?;
227
228 let strategy = match repo_ref
229 .as_ref()
230 .and_then(|reference| reference.vcs_kind.as_ref())
231 {
232 Some(VcsKind::Git) => WorkspaceCreateStrategy::GitWorktree,
233 _ => WorkspaceCreateStrategy::JjWorkspace,
234 };
235
236 let create_request = CreateWorkspaceRequest {
237 repo_id: base_repo_id,
238 name: requested_workspace_name.clone(),
239 parent_workspace_id: workspace_id,
240 strategy,
241 };
242
243 let info = manager
244 .create_workspace(create_request)
245 .await
246 .map_err(|e| {
247 StaticToolError::execution(DispatchAgentError::Workspace(
248 workspace_manager_op_error(e),
249 ))
250 })?;
251
252 workspace = manager
253 .open_workspace(info.workspace_id)
254 .await
255 .map_err(|e| {
256 StaticToolError::execution(DispatchAgentError::Workspace(
257 workspace_manager_op_error(e),
258 ))
259 })?;
260
261 workspace_id = Some(info.workspace_id);
262 created_workspace_id = Some(info.workspace_id);
263 workspace_name.clone_from(&info.name);
264 workspace_ref = Some(WorkspaceRef {
265 environment_id: info.environment_id,
266 workspace_id: info.workspace_id,
267 repo_id: info.repo_id,
268 });
269
270 if let Some(repo_manager) = ctx.services.repo_manager()
271 && let Ok(info) = repo_manager
272 .resolve_repo(info.environment_id, workspace.working_directory())
273 .await
274 {
275 repo_ref = Some(RepoRef {
276 environment_id: info.environment_id,
277 repo_id: info.repo_id,
278 root_path: info.root_path,
279 vcs_kind: info.vcs_kind,
280 });
281 }
282 }
283
284 let env_info = workspace.environment().await.map_err(|e| {
285 StaticToolError::execution(DispatchAgentError::Workspace(workspace_op_error(e)))
286 })?;
287
288 let system_prompt = format!(
289 r#"You are an agent for a CLI-based coding tool. Given the user's prompt, you should use the tools available to you to answer the user's question.
290
291Notes:
2921. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
2932. When relevant, share file names and code snippets relevant to the query
2943. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
295
296{}
297"#,
298 env_info.as_context()
299 );
300
301 let agent_id = agent
302 .as_deref()
303 .filter(|value| !value.trim().is_empty())
304 .map_or_else(|| default_agent_spec_id().to_string(), str::to_string);
305
306 let agent_spec = agent_spec(&agent_id).ok_or_else(|| {
307 let available = agent_specs()
308 .into_iter()
309 .map(|spec| spec.id)
310 .collect::<Vec<_>>()
311 .join(", ");
312 StaticToolError::invalid_params(format!(
313 "Unknown agent spec '{agent_id}'. Available: {available}"
314 ))
315 })?;
316
317 let parent_session_config = match ctx.services.event_store.load_events(ctx.session_id).await
318 {
319 Ok(events) => events.into_iter().find_map(|(_, event)| match event {
320 SessionEvent::SessionCreated { config, .. } => Some(*config),
321 _ => None,
322 }),
323 Err(err) => {
324 warn!(
325 session_id = %ctx.session_id,
326 "Failed to load parent session config for MCP servers: {err}"
327 );
328 None
329 }
330 };
331
332 let parent_mcp_backends = parent_session_config
333 .as_ref()
334 .map(|config| config.tool_config.backends.clone())
335 .unwrap_or_default();
336
337 let parent_model = parent_session_config
338 .as_ref()
339 .map_or_else(default_model, |config| config.default_model.clone());
340
341 let allow_mcp_tools = agent_spec.mcp_access.allow_mcp_tools();
342 let mcp_backends = match &agent_spec.mcp_access {
343 McpAccessPolicy::None => Vec::new(),
344 McpAccessPolicy::All => parent_mcp_backends,
345 McpAccessPolicy::Allowlist(servers) => parent_mcp_backends
346 .into_iter()
347 .filter(|backend| match backend {
348 BackendConfig::Mcp { server_name, .. } => {
349 servers.iter().any(|allowed| allowed == server_name)
350 }
351 })
352 .collect(),
353 };
354
355 let config = SubAgentConfig {
356 parent_session_id: ctx.session_id,
357 prompt,
358 allowed_tools: agent_spec.tools.clone(),
359 model: agent_spec.model.clone().unwrap_or(parent_model),
360 system_context: Some(crate::app::SystemContext::new(system_prompt)),
361 workspace: Some(workspace),
362 workspace_ref,
363 workspace_id,
364 repo_ref,
365 workspace_name,
366 mcp_backends,
367 allow_mcp_tools,
368 };
369
370 let spawn_result = spawner.spawn(config, ctx.cancellation_token.clone()).await;
371
372 let mut workspace_info = None;
373
374 if let (Some(manager), Some(workspace_id)) = (status_manager, created_workspace_id) {
375 let revision = match manager.get_workspace_status(workspace_id).await {
376 Ok(status) => match status.vcs {
377 Some(vcs) => match vcs.status {
378 VcsStatus::Jj(jj_status) => {
379 jj_status.working_copy.map(|wc| AgentWorkspaceRevision {
380 vcs_kind: "jj".to_string(),
381 revision_id: wc.commit_id,
382 summary: wc.description,
383 change_id: Some(wc.change_id),
384 })
385 }
386 VcsStatus::Git(_) => None,
387 },
388 None => None,
389 },
390 Err(err) => {
391 warn!(
392 workspace_id = %workspace_id.as_uuid(),
393 "Failed to get workspace status for sub-agent: {err}"
394 );
395 None
396 }
397 };
398
399 workspace_info = Some(AgentWorkspaceInfo {
400 workspace_id: Some(workspace_id.as_uuid().to_string()),
401 revision,
402 });
403 }
404
405 let result = spawn_result.map_err(|e| match e {
406 SubAgentError::Cancelled => StaticToolError::Cancelled,
407 other => StaticToolError::execution(DispatchAgentError::SpawnFailed {
408 message: other.to_string(),
409 }),
410 })?;
411
412 Ok(AgentResult {
413 content: result.final_message.extract_text(),
414 session_id: Some(result.session_id.to_string()),
415 workspace: workspace_info,
416 })
417 }
418}
419
420fn build_runtime_tool_executor(
421 workspace: Arc<dyn Workspace>,
422 parent_services: &Arc<ToolServices>,
423) -> Arc<ToolExecutor> {
424 let mut services = ToolServices::new(
425 workspace.clone(),
426 parent_services.event_store.clone(),
427 parent_services.api_client.clone(),
428 );
429
430 if let Some(spawner) = parent_services.agent_spawner() {
431 services = services.with_agent_spawner(spawner.clone());
432 }
433 if let Some(caller) = parent_services.model_caller() {
434 services = services.with_model_caller(caller.clone());
435 }
436 if let Some(manager) = parent_services.workspace_manager() {
437 services = services.with_workspace_manager(manager.clone());
438 }
439 if let Some(manager) = parent_services.repo_manager() {
440 services = services.with_repo_manager(manager.clone());
441 }
442 if parent_services
443 .capabilities()
444 .contains(Capabilities::NETWORK)
445 {
446 services = services.with_network();
447 }
448
449 let mut registry = ToolRegistry::new();
450 registry.register_static(GrepTool);
451 registry.register_static(GlobTool);
452 registry.register_static(LsTool);
453 registry.register_static(ViewTool);
454 registry.register_static(BashTool);
455 registry.register_static(EditTool);
456 registry.register_static(MultiEditTool);
457 registry.register_static(ReplaceTool);
458 registry.register_static(AstGrepTool);
459 registry.register_static(TodoReadTool);
460 registry.register_static(TodoWriteTool);
461 registry.register_static(DispatchAgentTool);
462 registry.register_static(FetchTool);
463
464 Arc::new(
465 ToolExecutor::with_components(
466 Arc::new(BackendRegistry::new()),
467 Arc::new(ValidatorRegistry::new()),
468 )
469 .with_static_tools(Arc::new(registry), Arc::new(services)),
470 )
471}
472
473async fn resume_agent_session(
474 session_id: SessionId,
475 prompt: String,
476 ctx: &StaticToolContext,
477) -> Result<AgentResult, StaticToolError<DispatchAgentError>> {
478 let events = ctx
479 .services
480 .event_store
481 .load_events(session_id)
482 .await
483 .map_err(|e| {
484 StaticToolError::execution(DispatchAgentError::SpawnFailed {
485 message: format!("Failed to load session {session_id}: {e}"),
486 })
487 })?;
488
489 let session_config = events
490 .into_iter()
491 .find_map(|(_, event)| match event {
492 SessionEvent::SessionCreated { config, .. } => Some(*config),
493 _ => None,
494 })
495 .ok_or_else(|| {
496 StaticToolError::execution(DispatchAgentError::SpawnFailed {
497 message: format!("Session {session_id} is missing a SessionCreated event"),
498 })
499 })?;
500
501 if session_config.parent_session_id != Some(ctx.session_id) {
502 return Err(StaticToolError::invalid_params(format!(
503 "Session {session_id} is not a child of current session {}",
504 ctx.session_id
505 )));
506 }
507
508 let workspace = create_workspace_from_session_config(&session_config.workspace)
509 .await
510 .map_err(|e| {
511 StaticToolError::execution(DispatchAgentError::SpawnFailed {
512 message: format!("Failed to open workspace for session {session_id}: {e}"),
513 })
514 })?;
515
516 let tool_executor = build_runtime_tool_executor(workspace, &ctx.services);
517 let runtime = RuntimeService::spawn(
518 ctx.services.event_store.clone(),
519 ctx.services.api_client.clone(),
520 tool_executor,
521 );
522
523 let run_result = OneShotRunner::run_in_session_with_cancel(
524 &runtime.handle,
525 session_id,
526 prompt,
527 session_config.default_model.clone(),
528 ctx.cancellation_token.clone(),
529 )
530 .await;
531
532 runtime.shutdown().await;
533
534 let run_result = run_result.map_err(|e| match e {
535 crate::error::Error::Cancelled => StaticToolError::Cancelled,
536 other => StaticToolError::execution(DispatchAgentError::SpawnFailed {
537 message: other.to_string(),
538 }),
539 })?;
540
541 Ok(AgentResult {
542 content: run_result.final_message.extract_text(),
543 session_id: Some(run_result.session_id.to_string()),
544 workspace: None,
545 })
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use crate::agents::{AgentSpec, AgentSpecError, McpAccessPolicy, register_agent_spec};
552 use crate::api::Client as ApiClient;
553 use crate::api::{ApiError, CompletionResponse, Provider};
554 use crate::app::conversation::{AssistantContent, Message, MessageData};
555 use crate::app::domain::session::EventStore;
556 use crate::app::domain::session::event_store::InMemoryEventStore;
557 use crate::app::domain::types::ToolCallId;
558 use crate::config::model::builtin;
559 use crate::model_registry::ModelRegistry;
560 use crate::session::state::{
561 ApprovalRulesOverrides, SessionConfig, SessionPolicyOverrides, ToolApprovalPolicyOverrides,
562 ToolFilter, ToolVisibility,
563 };
564 use crate::tools::McpTransport;
565 use crate::tools::services::{AgentSpawner, SubAgentError, SubAgentResult, ToolServices};
566 use async_trait::async_trait;
567 use std::collections::{HashMap, HashSet};
568 use std::sync::Mutex as StdMutex;
569 use tokio::time::{Duration, sleep};
570 use tokio_util::sync::CancellationToken;
571 use uuid::Uuid;
572
573 #[derive(Clone)]
574 struct StubProvider {
575 response: String,
576 }
577
578 impl StubProvider {
579 fn new(response: impl Into<String>) -> Self {
580 Self {
581 response: response.into(),
582 }
583 }
584 }
585
586 #[derive(Clone)]
587 struct CancelAwareProvider;
588
589 #[async_trait]
590 impl Provider for CancelAwareProvider {
591 fn name(&self) -> &'static str {
592 "cancel-aware"
593 }
594
595 async fn complete(
596 &self,
597 _model_id: &crate::config::model::ModelId,
598 _messages: Vec<Message>,
599 _system: Option<crate::app::SystemContext>,
600 _tools: Option<Vec<steer_tools::ToolSchema>>,
601 _call_options: Option<crate::config::model::ModelParameters>,
602 token: CancellationToken,
603 ) -> Result<CompletionResponse, ApiError> {
604 token.cancelled().await;
605 Err(ApiError::Cancelled {
606 provider: self.name().to_string(),
607 })
608 }
609 }
610
611 #[async_trait]
612 impl Provider for StubProvider {
613 fn name(&self) -> &'static str {
614 "stub"
615 }
616
617 async fn complete(
618 &self,
619 _model_id: &crate::config::model::ModelId,
620 _messages: Vec<Message>,
621 _system: Option<crate::app::SystemContext>,
622 _tools: Option<Vec<steer_tools::ToolSchema>>,
623 _call_options: Option<crate::config::model::ModelParameters>,
624 _token: CancellationToken,
625 ) -> Result<CompletionResponse, ApiError> {
626 Ok(CompletionResponse {
627 content: vec![AssistantContent::Text {
628 text: self.response.clone(),
629 }],
630 usage: None,
631 })
632 }
633 }
634
635 #[derive(Clone)]
636 struct StubAgentSpawner {
637 session_id: SessionId,
638 response: String,
639 }
640
641 #[async_trait]
642 impl AgentSpawner for StubAgentSpawner {
643 async fn spawn(
644 &self,
645 _config: crate::tools::services::SubAgentConfig,
646 _cancel_token: CancellationToken,
647 ) -> Result<SubAgentResult, SubAgentError> {
648 let timestamp = Message::current_timestamp();
649 let message = Message {
650 timestamp,
651 id: Message::generate_id("assistant", timestamp),
652 parent_message_id: None,
653 data: MessageData::Assistant {
654 content: vec![AssistantContent::Text {
655 text: self.response.clone(),
656 }],
657 },
658 };
659
660 Ok(SubAgentResult {
661 session_id: self.session_id,
662 final_message: message,
663 })
664 }
665 }
666
667 #[derive(Clone)]
668 struct CapturingAgentSpawner {
669 session_id: SessionId,
670 response: String,
671 captured: Arc<tokio::sync::Mutex<Option<crate::tools::services::SubAgentConfig>>>,
672 }
673
674 #[async_trait]
675 impl AgentSpawner for CapturingAgentSpawner {
676 async fn spawn(
677 &self,
678 config: crate::tools::services::SubAgentConfig,
679 _cancel_token: CancellationToken,
680 ) -> Result<SubAgentResult, SubAgentError> {
681 let mut guard = self.captured.lock().await;
682 *guard = Some(config);
683
684 let timestamp = Message::current_timestamp();
685 let message = Message {
686 timestamp,
687 id: Message::generate_id("assistant", timestamp),
688 parent_message_id: None,
689 data: MessageData::Assistant {
690 content: vec![AssistantContent::Text {
691 text: self.response.clone(),
692 }],
693 },
694 };
695
696 Ok(SubAgentResult {
697 session_id: self.session_id,
698 final_message: message,
699 })
700 }
701 }
702
703 #[derive(Clone)]
704 struct ToolCallThenTextProvider {
705 tool_call: steer_tools::ToolCall,
706 final_text: String,
707 call_count: Arc<StdMutex<usize>>,
708 }
709
710 impl ToolCallThenTextProvider {
711 fn new(tool_call: steer_tools::ToolCall, final_text: impl Into<String>) -> Self {
712 Self {
713 tool_call,
714 final_text: final_text.into(),
715 call_count: Arc::new(StdMutex::new(0)),
716 }
717 }
718 }
719
720 #[async_trait]
721 impl Provider for ToolCallThenTextProvider {
722 fn name(&self) -> &'static str {
723 "stub-tool-call"
724 }
725
726 async fn complete(
727 &self,
728 _model_id: &crate::config::model::ModelId,
729 _messages: Vec<Message>,
730 _system: Option<crate::app::SystemContext>,
731 _tools: Option<Vec<steer_tools::ToolSchema>>,
732 _call_options: Option<crate::config::model::ModelParameters>,
733 _token: CancellationToken,
734 ) -> Result<CompletionResponse, ApiError> {
735 let mut count = self
736 .call_count
737 .lock()
738 .expect("tool call counter lock poisoned");
739 let response = if *count == 0 {
740 CompletionResponse {
741 content: vec![AssistantContent::ToolCall {
742 tool_call: self.tool_call.clone(),
743 thought_signature: None,
744 }],
745 usage: None,
746 }
747 } else {
748 CompletionResponse {
749 content: vec![AssistantContent::Text {
750 text: self.final_text.clone(),
751 }],
752 usage: None,
753 }
754 };
755 *count += 1;
756 Ok(response)
757 }
758 }
759
760 #[tokio::test]
761 async fn resume_session_rejects_non_child() {
762 let event_store = Arc::new(InMemoryEventStore::new());
763 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
764 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
765 let api_client = Arc::new(ApiClient::new_with_deps(
766 crate::test_utils::test_llm_config_provider().unwrap(),
767 provider_registry,
768 model_registry,
769 ));
770 let workspace =
771 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
772 path: std::env::current_dir().unwrap(),
773 })
774 .await
775 .unwrap();
776
777 let parent_session_id = SessionId::new();
778 let session_id = SessionId::new();
779 let mut session_config = SessionConfig::read_only(builtin::claude_sonnet_4_5());
780 session_config.parent_session_id = Some(parent_session_id);
781
782 event_store.create_session(session_id).await.unwrap();
783 event_store
784 .append(
785 session_id,
786 &SessionEvent::SessionCreated {
787 config: Box::new(session_config),
788 metadata: std::collections::HashMap::new(),
789 parent_session_id: Some(parent_session_id),
790 },
791 )
792 .await
793 .unwrap();
794
795 let services = Arc::new(ToolServices::new(workspace, event_store, api_client));
796
797 let ctx = StaticToolContext {
798 tool_call_id: ToolCallId::new(),
799 session_id: SessionId::new(),
800 invoking_model: None,
801 cancellation_token: CancellationToken::new(),
802 services,
803 };
804
805 let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;
806
807 assert!(matches!(result, Err(StaticToolError::InvalidParams(_))));
808 }
809
810 #[tokio::test]
811 async fn resume_session_accepts_child_and_returns_message() {
812 let event_store = Arc::new(InMemoryEventStore::new());
813 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
814 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
815 let api_client = Arc::new(ApiClient::new_with_deps(
816 crate::test_utils::test_llm_config_provider().unwrap(),
817 provider_registry,
818 model_registry,
819 ));
820 let model = builtin::claude_sonnet_4_5();
821 api_client.insert_test_provider(
822 model.provider.clone(),
823 Arc::new(StubProvider::new("stub-response")),
824 );
825 let workspace =
826 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
827 path: std::env::current_dir().unwrap(),
828 })
829 .await
830 .unwrap();
831
832 let parent_session_id = SessionId::new();
833 let session_id = SessionId::new();
834 let mut session_config = SessionConfig::read_only(model.clone());
835 session_config.parent_session_id = Some(parent_session_id);
836
837 event_store.create_session(session_id).await.unwrap();
838 event_store
839 .append(
840 session_id,
841 &SessionEvent::SessionCreated {
842 config: Box::new(session_config),
843 metadata: std::collections::HashMap::new(),
844 parent_session_id: Some(parent_session_id),
845 },
846 )
847 .await
848 .unwrap();
849
850 let services = Arc::new(ToolServices::new(workspace, event_store, api_client));
851
852 let ctx = StaticToolContext {
853 tool_call_id: ToolCallId::new(),
854 session_id: parent_session_id,
855 invoking_model: None,
856 cancellation_token: CancellationToken::new(),
857 services,
858 };
859
860 let result = resume_agent_session(session_id, "ping".to_string(), &ctx)
861 .await
862 .unwrap();
863
864 assert!(result.content.contains("stub-response"));
865 assert_eq!(
866 result.session_id.as_deref(),
867 Some(session_id.to_string().as_str())
868 );
869 }
870
871 #[tokio::test]
872 async fn resume_session_honors_cancellation() {
873 let event_store = Arc::new(InMemoryEventStore::new());
874 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
875 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
876 let api_client = Arc::new(ApiClient::new_with_deps(
877 crate::test_utils::test_llm_config_provider().unwrap(),
878 provider_registry,
879 model_registry,
880 ));
881 let model = builtin::claude_sonnet_4_5();
882 api_client.insert_test_provider(model.provider.clone(), Arc::new(CancelAwareProvider));
883 let workspace =
884 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
885 path: std::env::current_dir().unwrap(),
886 })
887 .await
888 .unwrap();
889
890 let parent_session_id = SessionId::new();
891 let session_id = SessionId::new();
892 let mut session_config = SessionConfig::read_only(model);
893 session_config.parent_session_id = Some(parent_session_id);
894
895 event_store.create_session(session_id).await.unwrap();
896 event_store
897 .append(
898 session_id,
899 &SessionEvent::SessionCreated {
900 config: Box::new(session_config),
901 metadata: std::collections::HashMap::new(),
902 parent_session_id: Some(parent_session_id),
903 },
904 )
905 .await
906 .unwrap();
907
908 let services = Arc::new(ToolServices::new(workspace, event_store, api_client));
909
910 let cancel_token = CancellationToken::new();
911 let ctx = StaticToolContext {
912 tool_call_id: ToolCallId::new(),
913 session_id: parent_session_id,
914 invoking_model: None,
915 cancellation_token: cancel_token.clone(),
916 services,
917 };
918
919 let cancel_task = tokio::spawn(async move {
920 sleep(Duration::from_millis(10)).await;
921 cancel_token.cancel();
922 });
923
924 let result = resume_agent_session(session_id, "ping".to_string(), &ctx).await;
925 let _ = cancel_task.await;
926
927 assert!(matches!(result, Err(StaticToolError::Cancelled)));
928 }
929
930 #[tokio::test]
931 async fn dispatch_agent_returns_session_id() {
932 let event_store = Arc::new(InMemoryEventStore::new());
933 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
934 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
935 let api_client = Arc::new(ApiClient::new_with_deps(
936 crate::test_utils::test_llm_config_provider().unwrap(),
937 provider_registry,
938 model_registry,
939 ));
940 let workspace =
941 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
942 path: std::env::current_dir().unwrap(),
943 })
944 .await
945 .unwrap();
946
947 let session_id = SessionId::new();
948 let spawner = StubAgentSpawner {
949 session_id,
950 response: "done".to_string(),
951 };
952
953 let services = Arc::new(
954 ToolServices::new(workspace, event_store, api_client)
955 .with_agent_spawner(Arc::new(spawner)),
956 );
957
958 let ctx = StaticToolContext {
959 tool_call_id: ToolCallId::new(),
960 session_id: SessionId::new(),
961 invoking_model: None,
962 cancellation_token: CancellationToken::new(),
963 services,
964 };
965
966 let params = DispatchAgentParams {
967 prompt: "hello".to_string(),
968 target: DispatchAgentTarget::New {
969 workspace: WorkspaceTarget::Current,
970 agent: None,
971 },
972 };
973
974 let result = DispatchAgentTool.execute(params, &ctx).await.unwrap();
975 assert_eq!(
976 result.session_id.as_deref(),
977 Some(session_id.to_string().as_str())
978 );
979 }
980
981 #[tokio::test]
982 async fn dispatch_agent_filters_mcp_backends_by_allowlist() {
983 let event_store = Arc::new(InMemoryEventStore::new());
984 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
985 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
986 let api_client = Arc::new(ApiClient::new_with_deps(
987 crate::test_utils::test_llm_config_provider().unwrap(),
988 provider_registry,
989 model_registry,
990 ));
991 let workspace =
992 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
993 path: std::env::current_dir().unwrap(),
994 })
995 .await
996 .unwrap();
997
998 let parent_session_id = SessionId::new();
999 let mut session_config = SessionConfig::read_only(builtin::claude_sonnet_4_5());
1000 session_config
1001 .tool_config
1002 .backends
1003 .push(BackendConfig::Mcp {
1004 server_name: "allowed-server".to_string(),
1005 transport: McpTransport::Tcp {
1006 host: "127.0.0.1".to_string(),
1007 port: 1111,
1008 },
1009 tool_filter: ToolFilter::All,
1010 });
1011 session_config
1012 .tool_config
1013 .backends
1014 .push(BackendConfig::Mcp {
1015 server_name: "blocked-server".to_string(),
1016 transport: McpTransport::Tcp {
1017 host: "127.0.0.1".to_string(),
1018 port: 2222,
1019 },
1020 tool_filter: ToolFilter::All,
1021 });
1022
1023 event_store.create_session(parent_session_id).await.unwrap();
1024 event_store
1025 .append(
1026 parent_session_id,
1027 &SessionEvent::SessionCreated {
1028 config: Box::new(session_config),
1029 metadata: HashMap::new(),
1030 parent_session_id: None,
1031 },
1032 )
1033 .await
1034 .unwrap();
1035
1036 let agent_id = format!("allowlist_{}", Uuid::new_v4());
1037 let spec = AgentSpec {
1038 id: agent_id.clone(),
1039 name: "allowlist test".to_string(),
1040 description: "allowlist test".to_string(),
1041 tools: vec![VIEW_TOOL_NAME.to_string()],
1042 mcp_access: McpAccessPolicy::Allowlist(vec!["allowed-server".to_string()]),
1043 model: None,
1044 };
1045 match register_agent_spec(spec) {
1046 Ok(()) => {}
1047 Err(AgentSpecError::AlreadyRegistered(_)) => {}
1048 Err(AgentSpecError::RegistryPoisoned) => {}
1049 }
1050
1051 let captured = Arc::new(tokio::sync::Mutex::new(None));
1052 let spawner = CapturingAgentSpawner {
1053 session_id: SessionId::new(),
1054 response: "ok".to_string(),
1055 captured: captured.clone(),
1056 };
1057
1058 let services = Arc::new(
1059 ToolServices::new(workspace, event_store, api_client)
1060 .with_agent_spawner(Arc::new(spawner)),
1061 );
1062
1063 let ctx = StaticToolContext {
1064 tool_call_id: ToolCallId::new(),
1065 session_id: parent_session_id,
1066 invoking_model: None,
1067 cancellation_token: CancellationToken::new(),
1068 services,
1069 };
1070
1071 let params = DispatchAgentParams {
1072 prompt: "test".to_string(),
1073 target: DispatchAgentTarget::New {
1074 workspace: WorkspaceTarget::Current,
1075 agent: Some(agent_id),
1076 },
1077 };
1078
1079 let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
1080 let captured = captured.lock().await.clone().expect("no config captured");
1081
1082 let backend_names: Vec<String> = captured
1083 .mcp_backends
1084 .iter()
1085 .map(|backend| match backend {
1086 BackendConfig::Mcp { server_name, .. } => server_name.clone(),
1087 })
1088 .collect();
1089
1090 assert_eq!(backend_names, vec!["allowed-server".to_string()]);
1091 assert!(captured.allow_mcp_tools);
1092 }
1093
1094 #[tokio::test]
1095 async fn dispatch_agent_uses_parent_model_when_spec_missing_model() {
1096 let event_store = Arc::new(InMemoryEventStore::new());
1097 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
1098 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
1099 let api_client = Arc::new(ApiClient::new_with_deps(
1100 crate::test_utils::test_llm_config_provider().unwrap(),
1101 provider_registry,
1102 model_registry,
1103 ));
1104 let workspace =
1105 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
1106 path: std::env::current_dir().unwrap(),
1107 })
1108 .await
1109 .unwrap();
1110
1111 let parent_session_id = SessionId::new();
1112 let parent_model = builtin::claude_sonnet_4_5();
1113 let session_config = SessionConfig::read_only(parent_model.clone());
1114
1115 event_store.create_session(parent_session_id).await.unwrap();
1116 event_store
1117 .append(
1118 parent_session_id,
1119 &SessionEvent::SessionCreated {
1120 config: Box::new(session_config),
1121 metadata: HashMap::new(),
1122 parent_session_id: None,
1123 },
1124 )
1125 .await
1126 .unwrap();
1127
1128 let agent_id = format!("inherit_model_{}", Uuid::new_v4());
1129 let spec = AgentSpec {
1130 id: agent_id.clone(),
1131 name: "inherit model test".to_string(),
1132 description: "inherit model test".to_string(),
1133 tools: vec![VIEW_TOOL_NAME.to_string()],
1134 mcp_access: McpAccessPolicy::None,
1135 model: None,
1136 };
1137 match register_agent_spec(spec) {
1138 Ok(()) => {}
1139 Err(AgentSpecError::AlreadyRegistered(_)) => {}
1140 Err(AgentSpecError::RegistryPoisoned) => {}
1141 }
1142
1143 let captured = Arc::new(tokio::sync::Mutex::new(None));
1144 let spawner = CapturingAgentSpawner {
1145 session_id: SessionId::new(),
1146 response: "ok".to_string(),
1147 captured: captured.clone(),
1148 };
1149
1150 let services = Arc::new(
1151 ToolServices::new(workspace, event_store, api_client)
1152 .with_agent_spawner(Arc::new(spawner)),
1153 );
1154
1155 let ctx = StaticToolContext {
1156 tool_call_id: ToolCallId::new(),
1157 session_id: parent_session_id,
1158 invoking_model: None,
1159 cancellation_token: CancellationToken::new(),
1160 services,
1161 };
1162
1163 let params = DispatchAgentParams {
1164 prompt: "test".to_string(),
1165 target: DispatchAgentTarget::New {
1166 workspace: WorkspaceTarget::Current,
1167 agent: Some(agent_id),
1168 },
1169 };
1170
1171 let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
1172 let captured = captured.lock().await.clone().expect("no config captured");
1173
1174 assert_eq!(captured.model, parent_model);
1175 }
1176
1177 #[tokio::test]
1178 async fn dispatch_agent_uses_spec_model_when_set() {
1179 let event_store = Arc::new(InMemoryEventStore::new());
1180 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
1181 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
1182 let api_client = Arc::new(ApiClient::new_with_deps(
1183 crate::test_utils::test_llm_config_provider().unwrap(),
1184 provider_registry,
1185 model_registry,
1186 ));
1187 let workspace =
1188 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
1189 path: std::env::current_dir().unwrap(),
1190 })
1191 .await
1192 .unwrap();
1193
1194 let parent_session_id = SessionId::new();
1195 let parent_model = builtin::claude_sonnet_4_5();
1196 let session_config = SessionConfig::read_only(parent_model);
1197
1198 event_store.create_session(parent_session_id).await.unwrap();
1199 event_store
1200 .append(
1201 parent_session_id,
1202 &SessionEvent::SessionCreated {
1203 config: Box::new(session_config),
1204 metadata: HashMap::new(),
1205 parent_session_id: None,
1206 },
1207 )
1208 .await
1209 .unwrap();
1210
1211 let spec_model = builtin::claude_haiku_4_5();
1212 let agent_id = format!("spec_model_{}", Uuid::new_v4());
1213 let spec = AgentSpec {
1214 id: agent_id.clone(),
1215 name: "spec model test".to_string(),
1216 description: "spec model test".to_string(),
1217 tools: vec![VIEW_TOOL_NAME.to_string()],
1218 mcp_access: McpAccessPolicy::None,
1219 model: Some(spec_model.clone()),
1220 };
1221 match register_agent_spec(spec) {
1222 Ok(()) => {}
1223 Err(AgentSpecError::AlreadyRegistered(_)) => {}
1224 Err(AgentSpecError::RegistryPoisoned) => {}
1225 }
1226
1227 let captured = Arc::new(tokio::sync::Mutex::new(None));
1228 let spawner = CapturingAgentSpawner {
1229 session_id: SessionId::new(),
1230 response: "ok".to_string(),
1231 captured: captured.clone(),
1232 };
1233
1234 let services = Arc::new(
1235 ToolServices::new(workspace, event_store, api_client)
1236 .with_agent_spawner(Arc::new(spawner)),
1237 );
1238
1239 let ctx = StaticToolContext {
1240 tool_call_id: ToolCallId::new(),
1241 session_id: parent_session_id,
1242 invoking_model: None,
1243 cancellation_token: CancellationToken::new(),
1244 services,
1245 };
1246
1247 let params = DispatchAgentParams {
1248 prompt: "test".to_string(),
1249 target: DispatchAgentTarget::New {
1250 workspace: WorkspaceTarget::Current,
1251 agent: Some(agent_id),
1252 },
1253 };
1254
1255 let _ = DispatchAgentTool.execute(params, &ctx).await.unwrap();
1256 let captured = captured.lock().await.clone().expect("no config captured");
1257
1258 assert_eq!(captured.model, spec_model);
1259 }
1260
1261 #[tokio::test]
1262 async fn resume_session_rejects_invisible_tools_as_unknown() {
1263 let event_store = Arc::new(InMemoryEventStore::new());
1264 let model_registry = Arc::new(ModelRegistry::load(&[]).unwrap());
1265 let provider_registry = Arc::new(crate::auth::ProviderRegistry::load(&[]).unwrap());
1266 let api_client = Arc::new(ApiClient::new_with_deps(
1267 crate::test_utils::test_llm_config_provider().unwrap(),
1268 provider_registry,
1269 model_registry,
1270 ));
1271 let workspace =
1272 crate::workspace::create_workspace(&steer_workspace::WorkspaceConfig::Local {
1273 path: std::env::current_dir().unwrap(),
1274 })
1275 .await
1276 .unwrap();
1277
1278 let parent_session_id = SessionId::new();
1279 let session_id = SessionId::new();
1280 let model = builtin::claude_sonnet_4_5();
1281
1282 let tool_call = steer_tools::ToolCall {
1283 name: "bash".to_string(),
1284 parameters: serde_json::json!({ "command": "echo denied" }),
1285 id: "tool_denied".to_string(),
1286 };
1287 api_client.insert_test_provider(
1288 model.provider.clone(),
1289 Arc::new(ToolCallThenTextProvider::new(tool_call, "done")),
1290 );
1291
1292 let mut session_config = SessionConfig::read_only(model);
1293 session_config.parent_session_id = Some(parent_session_id);
1294 session_config.policy_overrides = SessionPolicyOverrides {
1295 default_model: None,
1296 tool_visibility: Some(ToolVisibility::Whitelist(HashSet::from([
1297 VIEW_TOOL_NAME.to_string()
1298 ]))),
1299 approval_policy: ToolApprovalPolicyOverrides {
1300 preapproved: ApprovalRulesOverrides {
1301 tools: HashSet::from([VIEW_TOOL_NAME.to_string()]),
1302 per_tool: HashMap::new(),
1303 },
1304 },
1305 };
1306
1307 event_store.create_session(session_id).await.unwrap();
1308 event_store
1309 .append(
1310 session_id,
1311 &SessionEvent::SessionCreated {
1312 config: Box::new(session_config),
1313 metadata: HashMap::new(),
1314 parent_session_id: Some(parent_session_id),
1315 },
1316 )
1317 .await
1318 .unwrap();
1319
1320 let services = Arc::new(ToolServices::new(
1321 workspace,
1322 event_store.clone(),
1323 api_client,
1324 ));
1325
1326 let ctx = StaticToolContext {
1327 tool_call_id: ToolCallId::new(),
1328 session_id: parent_session_id,
1329 invoking_model: None,
1330 cancellation_token: CancellationToken::new(),
1331 services,
1332 };
1333
1334 let _ = resume_agent_session(session_id, "trigger".to_string(), &ctx)
1335 .await
1336 .unwrap();
1337
1338 let events = event_store.load_events(session_id).await.unwrap();
1339 let unknown = events.iter().any(|(_, event)| match event {
1340 SessionEvent::ToolCallFailed { name, error, .. } => {
1341 name == "bash" && error.contains("Unknown tool")
1342 }
1343 _ => false,
1344 });
1345
1346 assert!(
1347 unknown,
1348 "expected unknown-tool ToolCallFailed event for invisible bash"
1349 );
1350 }
1351}