Skip to main content

steer_core/tools/static_tools/
dispatch_agent.rs

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}