//! ToolBridge: adapter that wraps `kigi-tools`'s `ToolRegistry` and //! exposes it through a session layer. //! //! The bridge: //! 1. Owns a `ToolRegistry` with all built-in tools registered //! 2. Dispatches tool calls via `call_new_tool()` //! 3. Manages tool definitions, enable/disable, name overrides use std::sync::Arc; use crate::computer::types::KillOutcome; use crate::computer::types::TaskKind; use crate::computer::types::TerminalBackend; use crate::registry::types::{ FinalizedToolset, SessionContext, ToolRegistryBuilder, ToolServerConfig, }; use crate::types::TaskSnapshot; use crate::types::ToolInput; use crate::types::agents_md_tracker::AgentsMdTracker; use crate::types::definition::ToolDefinition; use crate::types::output::{ToolOutput, ToolRunResult}; use crate::types::resources::{OwnerSessionId, State, Terminal}; use crate::types::template_renderer::TemplateRenderer; use crate::types::tool::ToolKind; /// Result of executing a tool through the bridge. /// /// Carries all the data the session needs to: /// 1. Send ACP notifications (from `output`) /// 2. Build the model prompt (from `prompt_text`) #[derive(Debug)] pub struct ToolBridgeResult { /// Clean tool output — for JSON serialization, ACP conversion, hunk tracking. pub output: ToolOutput, /// Prompt-ready text — with system reminders appended. pub prompt_text: String, } impl From for ToolBridgeResult { fn from(result: ToolRunResult) -> Self { Self { output: result.output, prompt_text: result.prompt_text, } } } /// Bridges the `ToolRegistry` into a session layer. /// /// Owns the registry and dispatches tool calls via `call_new_tool()`. /// All state lives in `Resources` on the registry — no separate `ToolState`. /// /// # Cancellation Safety /// /// The `terminal` field is stored separately from the registry lock to enable /// cancellation during tool execution. When a bash command is running, the /// registry lock is held by `call()`. If the user cancels, `kill_foreground_commands()` /// needs to access the terminal without blocking on the lock. #[derive(Clone)] pub struct ToolBridge { registry: Arc, terminal: Option>, } impl ToolBridge { pub fn get_builder() -> ToolRegistryBuilder { ToolRegistryBuilder::new() } pub async fn finalize_builder( builder: ToolRegistryBuilder, config: ToolServerConfig, ctx: SessionContext, ) -> Result { let finalized_toolset = builder.finalize(config, ctx).map_err(|errs| { kigi_tool_runtime::ToolError::invalid_arguments(format!( "Requirements unsatisfied: {errs:?}" )) })?; let terminal; { terminal = finalized_toolset .resources .lock() .await .get::() .map(|t| t.0.clone()); } Ok(Self { registry: Arc::new(finalized_toolset), terminal, }) } pub async fn tool_definitions(&self) -> Vec { self.registry.tool_definitions() } /// Returns the client-facing name of the tool registered with the given /// `ToolKind`, if any. Looks up the kind->name map populated by /// `FinalizedToolset` from each tool's `kind()`. Useful for "does this /// agent have a way to do X?" checks where the X is identified by kind /// rather than by namespaced id. /// /// Example: `tool_for_kind(ToolKind::BackgroundTaskAction)` returns /// `Some("get_task_output")` for the kigi agent and `None` for /// agents that do not register a tool of that kind. pub async fn tool_for_kind(&self, kind: ToolKind) -> Option { self.registry .resources .lock() .await .get::() .and_then(|r| r.tool_for_kind(kind).map(str::to_string)) } /// [`ToolKind`] for a registered tool by client-facing name, or /// `None` for unknown names. Sync — uses the registry's /// `RwLock::read`. pub fn tool_kind(&self, tool_name: &str) -> Option { self.registry.get_tool_metadata(tool_name).map(|m| m.kind()) } /// Get only built-in tool definitions (exclude MCP tools). pub async fn tool_definitions_builtins_only(&self) -> Vec { self.registry.tool_definitions_builtins_only() } /// Render a prompt template through [`TemplateRenderer`] with extra /// agent-specific context fields. /// /// The template can use both `${{ tools.by_kind.* }}` (resolved from /// the finalized tool registry) and caller-provided fields like /// `${{ os_name }}`, `${{ memory_enabled }}`, etc. /// /// Returns `None` if the renderer is not yet available. pub async fn render_prompt( &self, template: &str, placeholders: &serde_json::Value, ) -> Option { let registry = &*self.registry; let result; { result = registry .resources .lock() .await .get::() .and_then(|r| r.render_with_extra(template, placeholders).ok()); } result } pub async fn register_mcp_tools( &self, mcp_name: String, tool: T, input_schema: Option, ) -> Result<(), kigi_tool_runtime::ToolError> where T: kigi_tool_runtime::Tool + crate::types::tool_metadata::ToolMetadata + std::fmt::Debug + Send + Sync + 'static, T::Output: serde::Serialize, { self.registry.register_tool(mcp_name, tool, input_schema)?; Ok(()) } pub fn unregister_tools_by_prefix(&self, prefix: &str) -> usize { self.registry.unregister_tools_by_prefix(prefix) } pub fn unregister_tool_by_name(&self, name: &str) -> bool { self.registry.unregister_tool_by_name(name) } /// Access the underlying `FinalizedToolset`. /// /// Used by `WorkspaceOps::bind_local_session` to install the agent's /// toolset on the workspace session so local-mode tool calls dispatch /// through the workspace. pub fn toolset(&self) -> Arc { Arc::clone(&self.registry) } pub async fn call( &self, client_function_name: &str, client_params: serde_json::Value, tool_call_id: &str, ) -> Result { self.registry .call(client_function_name, client_params, tool_call_id, None) .await } pub async fn try_parse( &self, client_function_name: &str, client_params: serde_json::Value, ) -> Result { self.registry .try_parse(client_function_name, &client_params) .await } /// Seed the AGENTS.md tracker. /// /// `compat` gates which rules dirs and agent filenames runtime discovery /// scans. Defaults to all-on at the caller for historical behavior. pub async fn seed_agents_md( &self, initial_paths: Vec, git_root: Option, initial_chain: Vec, gitignore: Option, compat: crate::types::compat::CompatConfig, ) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.set_compat(compat); tracker .seed(initial_paths, git_root, initial_chain, gitignore) .await; } } /// Restore announced skill names from persisted state. /// /// Must be called BEFORE `seed_skill_discovery()` so that `seed()` /// sees non-empty `announced_names` and skips the BaselineChange pending. pub async fn restore_announced_skill_names(&self, names: std::collections::HashSet) { let registry = &*self.registry; let mut res = registry.resources.lock().await; let tracker = res.get_or_default::(); tracker.restore_announced_names(names); } /// Get the current set of announced skill names (for persistence). pub async fn get_announced_skill_names(&self) -> std::collections::HashSet { let registry = &*self.registry; let res = registry.resources.lock().await; res.get::() .map(|t| t.announced_names().clone()) .unwrap_or_default() } /// The model-facing skill listing rendered from the current skill set, /// for `/context` accounting. `None` when no skill qualifies. pub async fn skill_listing_snapshot( &self, ) -> Option { let registry = &*self.registry; let res = registry.resources.lock().await; res.get::() .and_then(|t| t.listing_snapshot()) } /// Seed the SkillDiscoveryTracker with session context and startup skills. /// /// Must be called at session start so the `SkillDiscoveryReminder` can /// discover skills in subdirectories. /// `display_cwd`: If set (forked sessions), skill paths in /// model-visible announcements are rewritten from real cwd to this /// value. Runtime invocation uses the real path. pub async fn seed_skill_discovery( &self, cwd: Option, git_root: Option, startup_skills: Vec, display_cwd: Option, context_window_tokens: Option, skill_budget_percent: Option, compat: crate::types::compat::CompatConfig, ) { let registry = &*self.registry; let mut res = registry.resources.lock().await; // Resolve client-facing tool names from the template renderer // so listing headers and descriptions use the correct (possibly randomized) names. let renderer = res.get::(); let skill_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.skill }}").ok()); let read_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.read }}").ok()); let tracker = res.get_or_default::(); if let Some(name) = skill_tool_name { tracker.set_skill_tool_name(name); } if let Some(name) = read_tool_name { tracker.set_read_tool_name(name); } tracker.set_compat(compat); tracker.seed( cwd, git_root, startup_skills, display_cwd, context_window_tokens, skill_budget_percent, ); } /// Enable XML formatting for mid-session skill announcements. /// /// When set, `take_pending()` produces `` XML rows instead of /// markdown, matching the startup `` preamble format. pub async fn set_skill_listing_xml_format(&self, enabled: bool) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.set_xml_format(enabled); } } /// Seed the gitignore filter so `read_file` and `search_replace` refuse /// to access gitignored paths (matching `list_dir`/`grep` behavior). pub async fn seed_gitignore_filter( &self, gitignore: ignore::gitignore::Gitignore, git_root: std::path::PathBuf, ) { let registry = &*self.registry; let mut res = registry.resources.lock().await; res.insert(crate::types::resources::GitignoreFilter::new( gitignore, git_root, )); } pub async fn on_agents_md_compaction(&self) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.on_compaction(); } } /// Clear `announced_names` and `checked_dirs` so skills get re-announced /// and re-discovered after compaction. pub async fn on_skill_discovery_compaction(&self) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.on_compaction(); } } /// Full reset of skill discovery state for /clear. /// Startup baseline is preserved; a pending reconciliation is queued. pub async fn on_skill_discovery_clear(&self) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.on_clear(); } } /// Replace the startup baseline (plugin reload). /// Dynamic discoveries are preserved; a pending reconciliation is queued. pub async fn update_skill_baseline( &self, new_skills: Vec, ) { let registry = &*self.registry; let mut res = registry.resources.lock().await; if let Some(tracker) = res.get_mut::() { tracker.update_startup_baseline(new_skills); } } /// Apply any pending skill updates. /// /// If the tracker has a pending change (discovery, baseline update, /clear), /// this method: /// 1. Computes runtime and display projections internally. /// 2. Writes the runtime projection into `AvailableSkills` in Resources. /// 3. Returns `SkillUpdateEffects` with conversation/UI side-effects /// for the session to execute (system-reminder injection, slash /// command refresh, prompt finalization). /// /// Returns `None` if nothing changed. pub async fn apply_pending_skill_update( &self, ) -> Option { let registry = &*self.registry; let mut res = registry.resources.lock().await; let tracker = res.get_mut::()?; let (runtime_skills, effects) = tracker.take_pending()?; // Write the runtime projection directly -- the shell never sees this. res.insert(crate::types::resources::AvailableSkills(runtime_skills)); Some(effects) } /// Get the current display-deduped skill list for slash commands. /// /// Returns the combined (startup + discovered) list with canonical-path /// and name dedup applied. This is the authoritative source for slash /// command advertisement — PromptContext is NOT used. pub async fn slash_skills(&self) -> Vec { let registry = &*self.registry; let res = registry.resources.lock().await; res.get::() .map(|m| m.slash_skills()) .unwrap_or_default() } /// Get the paths that have been reminded about. pub async fn agents_md_reminded_paths(&self) -> std::collections::HashSet { let registry = &*self.registry; let result; { result = registry .resources .lock() .await .get::() .map(|t| t.reminded_paths().clone()) .unwrap_or_default(); } result } /// Set the stable display path for forked sessions. /// /// Inserts [`DisplayCwd`] into the tool registry's [`Resources`] so that /// tools can use [`resolve_model_path`] and [`display_cwd_or_cwd`] to /// rewrite model-provided paths and format output paths correctly. pub async fn set_display_cwd(&self, display_cwd: std::path::PathBuf) { let registry = &*self.registry; registry .resources .lock() .await .insert(crate::types::resources::DisplayCwd(display_cwd)); } /// List all known background tasks from the terminal backend. /// Used for context compaction to include task state in summaries. pub async fn list_background_tasks(&self) -> Vec { if let Some(terminal) = &self.terminal { terminal.list_tasks().await } else { vec![] } } /// Kill all foreground terminal commands. pub async fn kill_foreground_commands(&self) { if let Some(terminal) = &self.terminal { terminal.kill_foreground_commands().await; } } /// Kill all running foreground processes owned by a specific session. pub async fn kill_foreground_commands_by_owner(&self, owner_session_id: &str) { if let Some(terminal) = &self.terminal { terminal .kill_foreground_commands_by_owner(owner_session_id) .await; } } /// Kill all running background tasks. pub async fn kill_all_background_tasks(&self) { if let Some(terminal) = &self.terminal { terminal.kill_all_background_tasks().await; } } /// Kill all running background tasks owned by a specific session. /// Used during subagent teardown on a shared terminal backend. pub async fn kill_all_background_tasks_by_owner(&self, owner_session_id: &str) { if let Some(terminal) = &self.terminal { terminal .kill_all_background_tasks_by_owner(owner_session_id) .await; } } /// Reparent notification handles for tasks owned by `old_owner_session_id`. pub async fn reparent_notifications( &self, old_owner_session_id: &str, new_owner_session_id: &str, new_handle: crate::notification::types::ToolNotificationHandle, ) { if let Some(terminal) = &self.terminal { // Weak anchored by this bridge's backend `Arc` (lives as long as the session). let backend_weak = std::sync::Arc::downgrade(terminal); terminal .reparent_notifications( old_owner_session_id, new_owner_session_id, new_handle, backend_weak, ) .await; } } /// Read a typed resource from the registry. /// /// Returns `None` if the resource type has never been inserted. /// The resource is cloned so no lock is held after this returns. pub async fn read_resource(&self) -> Option { self.registry.resources.lock().await.get::().cloned() } /// Get the shared resources handle for direct access. /// Used by the skill reconciliation helper which needs to update /// `AvailableSkills` in the Resources directly. pub async fn shared_resources(&self) -> crate::types::resources::SharedResources { self.registry.resources.clone() } /// Insert a typed resource into the registry's `Resources`. /// Used by the host session to inject `ToolIndex` for search_tool. pub async fn update_resource(&self, resource: T) { let _ = self.registry.update_resource(resource).await; } /// Kill any background task pub async fn kill_background_task( &self, task_id: &str, ) -> Result { if let Some(terminal) = &self.terminal { Ok(terminal.kill_task(task_id).await) } else { Err(kigi_tool_runtime::ToolError::invalid_arguments(format!( "Missing Task Id: {task_id}" ))) } } pub async fn delete_scheduled_task( &self, task_id: &str, ) -> Result { use crate::implementations::kigi::scheduler::types::{SchedulerCommand, SchedulerHandle}; let sender = { let res = self.registry.resources.lock().await; res.get::() .ok_or_else(|| { kigi_tool_runtime::ToolError::custom("missing_resource", "SchedulerHandle") })? .0 .clone() }; let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); sender .send(SchedulerCommand::Delete { id: task_id.to_owned(), reply: reply_tx, }) .map_err(|_| { kigi_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped") })?; reply_rx.await.map_err(|_| { kigi_tool_runtime::ToolError::custom("process_manager", "Scheduler actor dropped reply") }) } /// Move a foreground command to background by tool_call_id. /// Returns `true` if a matching foreground process was found and unblocked. pub async fn background_foreground_command(&self, tool_call_id: &str) -> bool { if let Some(terminal) = &self.terminal { terminal.background_foreground_command(tool_call_id).await } else { false } } /// Gives the output of all terminal tasks which are managed by the tool bridge pub async fn list_tasks(&self) -> Option> { if let Some(terminal) = &self.terminal { Some(terminal.list_tasks().await) } else { None } } /// Drain newly-completed bash background tasks not yet reported. /// Marks returned tasks in [`ReportedTaskCompletions`] to prevent /// duplicate reminders from [`TaskCompletionReminder`]. pub async fn drain_between_turn_bash_completions(&self) -> Vec { let tasks = match self.list_tasks().await { Some(t) => t, None => return Vec::new(), }; let completed: Vec = tasks .into_iter() .filter(|t| t.completed && t.kind != TaskKind::Monitor) .collect(); if completed.is_empty() { return Vec::new(); } use crate::reminders::task_completion::{ReportedTaskCompletions, task_owned_by_session}; let mut res = self.registry.resources.lock().await; // Subagents share the parent's terminal backend, so `list_tasks()` // returns tasks owned by other sessions. Scope the between-turn // "While you were idle, … background task completed" drain to tasks // this session owns, mirroring the per-tool-call // `TaskCompletionReminder` filter — otherwise a parent (or sibling) // bash task that finished mid-subagent-turn leaks its completion // `` into the subagent's conversation. The owner // filter runs before `mark_reported` so the owning session still // reports the task on its own next turn. let my_owner = res.get::().map(|o| o.0.clone()); let state = res.get_or_default::>(); completed .into_iter() .filter(|t| task_owned_by_session(t, my_owner.as_deref())) .filter(|t| state.mark_reported(&t.task_id)) .collect() } /// Construct a minimal bridge for tests. Has no tools registered. /// /// Bypasses `ToolRegistryBuilder::finalize()` entirely so this can /// be called from sync `#[test]` functions that lack a tokio runtime. /// (`finalize()` spawns background tasks via `tokio::spawn`.) pub fn for_test() -> Self { let toolset = FinalizedToolset::empty_for_test(); Self { registry: Arc::new(toolset), terminal: None, } } } #[cfg(test)] mod tests { use super::*; use crate::computer::types::{BackgroundHandle, TerminalRunRequest, TerminalRunResult}; use crate::reminders::task_completion::ReportedTaskCompletions; use std::time::Duration; #[derive(Debug)] struct KindFixture { kind: ToolKind, id: &'static str, } impl crate::types::tool_metadata::ToolMetadata for KindFixture { fn kind(&self) -> ToolKind { self.kind } fn tool_namespace(&self) -> crate::types::tool::ToolNamespace { crate::types::tool::ToolNamespace::MCP } fn description_template(&self) -> &str { "kind fixture" } } impl kigi_tool_runtime::Tool for KindFixture { type Args = serde_json::Value; type Output = String; fn id(&self) -> kigi_tool_protocol::ToolId { kigi_tool_protocol::ToolId::new(self.id).expect("valid id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { kigi_tool_types::ToolDescription::new(self.id, "kind fixture") } async fn run( &self, _ctx: kigi_tool_runtime::ToolCallContext, _input: serde_json::Value, ) -> Result { Ok("ok".into()) } } fn register_fixture(toolset: &FinalizedToolset, name: &str, kind: ToolKind, id: &'static str) { toolset .register_tool( name.into(), KindFixture { kind, id }, Some(serde_json::json!({"type": "object", "properties": {}})), ) .unwrap(); } #[test] fn tool_kind_returns_registered_kind_per_namespace_and_none_for_unknown() { let bridge = ToolBridge::for_test(); let toolset = bridge.toolset(); // PascalCase + kigi's snake_case in one registry // to exercise the lookup on the literal name strings each // namespace ships. register_fixture(&toolset, "Write", ToolKind::Write, "fixture_write"); register_fixture( &toolset, "StrReplace", ToolKind::Edit, "fixture_str_replace", ); register_fixture(&toolset, "Delete", ToolKind::Delete, "fixture_delete"); register_fixture( &toolset, "run_terminal_cmd", ToolKind::Execute, "fixture_run_terminal_cmd", ); assert_eq!(bridge.tool_kind("Write"), Some(ToolKind::Write)); assert_eq!(bridge.tool_kind("StrReplace"), Some(ToolKind::Edit)); assert_eq!(bridge.tool_kind("Delete"), Some(ToolKind::Delete)); assert_eq!( bridge.tool_kind("run_terminal_cmd"), Some(ToolKind::Execute) ); assert_eq!(bridge.tool_kind("not_a_registered_tool"), None); // Exact client-name lookup is case-sensitive. assert_eq!(bridge.tool_kind("write"), None); } // ── drain_between_turn_bash_completions owner scoping (the "While you // were idle, … background task completed" path) ── #[derive(Debug)] struct MockTerminal { tasks: Vec, } #[async_trait::async_trait] impl TerminalBackend for MockTerminal { async fn run( &self, _: TerminalRunRequest, ) -> Result { unimplemented!() } async fn run_background( &self, _: TerminalRunRequest, ) -> Result { unimplemented!() } async fn kill_task(&self, _: &str) -> KillOutcome { KillOutcome::NotFound } async fn get_task(&self, _: &str) -> Option { None } async fn wait_for_completion(&self, _: &str, _: Option) -> Option { None } async fn list_tasks(&self) -> Vec { self.tasks.clone() } } fn completed_task(id: &str, owner: Option<&str>) -> TaskSnapshot { TaskSnapshot { task_id: id.into(), command: "echo test".into(), display_command: None, cwd: String::new(), start_time: std::time::SystemTime::now(), end_time: Some(std::time::SystemTime::now()), output: String::new(), output_file: std::path::PathBuf::new(), truncated: false, exit_code: Some(0), signal: None, completed: true, kind: Default::default(), block_waited: false, explicitly_killed: false, owner_session_id: owner.map(|s| s.to_string()), } } /// Regression: subagents share the parent's terminal backend, so the /// between-turn drain must not surface another session's completed bash /// task (which leaked as a "While you were idle, 1 background task /// completed" `` into the subagent's conversation). #[tokio::test] async fn between_turn_bash_completions_scoped_to_owning_session() { let toolset = FinalizedToolset::empty_for_test(); { let mut res = toolset.resources.lock().await; res.insert(OwnerSessionId("subagent-1".into())); res.register_state::(); } let backend: Arc = Arc::new(MockTerminal { tasks: vec![ completed_task("mine-task", Some("subagent-1")), completed_task("parent-task", Some("parent-0")), completed_task("unowned-task", None), ], }); let bridge = ToolBridge { registry: Arc::new(toolset), terminal: Some(backend), }; let drained = bridge.drain_between_turn_bash_completions().await; let ids: Vec<&str> = drained.iter().map(|t| t.task_id.as_str()).collect(); assert!(ids.contains(&"mine-task"), "own task must drain: {ids:?}"); assert!( ids.contains(&"unowned-task"), "unowned task must drain (backwards compat): {ids:?}" ); assert!( !ids.contains(&"parent-task"), "another session's task must NOT leak into this session: {ids:?}" ); } }