The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
832 lines
30 KiB
Rust
832 lines
30 KiB
Rust
//! 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<ToolRunResult> 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<FinalizedToolset>,
|
|
terminal: Option<Arc<dyn TerminalBackend>>,
|
|
}
|
|
|
|
impl ToolBridge {
|
|
pub fn get_builder() -> ToolRegistryBuilder {
|
|
ToolRegistryBuilder::new()
|
|
}
|
|
|
|
pub async fn finalize_builder(
|
|
builder: ToolRegistryBuilder,
|
|
config: ToolServerConfig,
|
|
ctx: SessionContext,
|
|
) -> Result<Self, kigi_tool_runtime::ToolError> {
|
|
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::<Terminal>()
|
|
.map(|t| t.0.clone());
|
|
}
|
|
|
|
Ok(Self {
|
|
registry: Arc::new(finalized_toolset),
|
|
terminal,
|
|
})
|
|
}
|
|
|
|
pub async fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
|
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<String> {
|
|
self.registry
|
|
.resources
|
|
.lock()
|
|
.await
|
|
.get::<TemplateRenderer>()
|
|
.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<ToolKind> {
|
|
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<ToolDefinition> {
|
|
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<String> {
|
|
let registry = &*self.registry;
|
|
let result;
|
|
{
|
|
result = registry
|
|
.resources
|
|
.lock()
|
|
.await
|
|
.get::<TemplateRenderer>()
|
|
.and_then(|r| r.render_with_extra(template, placeholders).ok());
|
|
}
|
|
result
|
|
}
|
|
|
|
pub async fn register_mcp_tools<T>(
|
|
&self,
|
|
mcp_name: String,
|
|
tool: T,
|
|
input_schema: Option<serde_json::Value>,
|
|
) -> 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<FinalizedToolset> {
|
|
Arc::clone(&self.registry)
|
|
}
|
|
|
|
pub async fn call(
|
|
&self,
|
|
client_function_name: &str,
|
|
client_params: serde_json::Value,
|
|
tool_call_id: &str,
|
|
) -> Result<ToolRunResult, kigi_tool_runtime::ToolError> {
|
|
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<ToolInput, kigi_tool_runtime::ToolError> {
|
|
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<std::path::PathBuf>,
|
|
git_root: Option<std::path::PathBuf>,
|
|
initial_chain: Vec<std::path::PathBuf>,
|
|
gitignore: Option<ignore::gitignore::Gitignore>,
|
|
compat: crate::types::compat::CompatConfig,
|
|
) {
|
|
let registry = &*self.registry;
|
|
let mut res = registry.resources.lock().await;
|
|
if let Some(tracker) = res.get_mut::<AgentsMdTracker>() {
|
|
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<String>) {
|
|
let registry = &*self.registry;
|
|
let mut res = registry.resources.lock().await;
|
|
let tracker = res.get_or_default::<crate::types::skill_discovery_tracker::SkillManager>();
|
|
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<String> {
|
|
let registry = &*self.registry;
|
|
let res = registry.resources.lock().await;
|
|
res.get::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
.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<crate::types::skill_discovery_tracker::SkillListingSnapshot> {
|
|
let registry = &*self.registry;
|
|
let res = registry.resources.lock().await;
|
|
res.get::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
.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<std::path::PathBuf>,
|
|
git_root: Option<std::path::PathBuf>,
|
|
startup_skills: Vec<crate::implementations::skills::types::SkillInfo>,
|
|
display_cwd: Option<String>,
|
|
context_window_tokens: Option<u64>,
|
|
skill_budget_percent: Option<f64>,
|
|
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::<TemplateRenderer>();
|
|
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::<crate::types::skill_discovery_tracker::SkillManager>();
|
|
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 `<agent_skill>` XML rows instead of
|
|
/// markdown, matching the startup `<agent_skills>` 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::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
{
|
|
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::<AgentsMdTracker>() {
|
|
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::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
{
|
|
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::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
{
|
|
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<crate::implementations::skills::types::SkillInfo>,
|
|
) {
|
|
let registry = &*self.registry;
|
|
let mut res = registry.resources.lock().await;
|
|
if let Some(tracker) = res.get_mut::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
{
|
|
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<crate::types::skill_discovery_tracker::SkillUpdateEffects> {
|
|
let registry = &*self.registry;
|
|
let mut res = registry.resources.lock().await;
|
|
let tracker = res.get_mut::<crate::types::skill_discovery_tracker::SkillManager>()?;
|
|
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<crate::implementations::skills::types::SkillInfo> {
|
|
let registry = &*self.registry;
|
|
let res = registry.resources.lock().await;
|
|
res.get::<crate::types::skill_discovery_tracker::SkillManager>()
|
|
.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<std::path::PathBuf> {
|
|
let registry = &*self.registry;
|
|
let result;
|
|
{
|
|
result = registry
|
|
.resources
|
|
.lock()
|
|
.await
|
|
.get::<AgentsMdTracker>()
|
|
.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<crate::computer::types::TaskSnapshot> {
|
|
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<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
|
|
self.registry.resources.lock().await.get::<T>().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<T: Send + Sync + 'static>(&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<KillOutcome, kigi_tool_runtime::ToolError> {
|
|
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<bool, kigi_tool_runtime::ToolError> {
|
|
use crate::implementations::kigi::scheduler::types::{SchedulerCommand, SchedulerHandle};
|
|
let sender = {
|
|
let res = self.registry.resources.lock().await;
|
|
res.get::<SchedulerHandle>()
|
|
.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<Vec<TaskSnapshot>> {
|
|
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<TaskSnapshot> {
|
|
let tasks = match self.list_tasks().await {
|
|
Some(t) => t,
|
|
None => return Vec::new(),
|
|
};
|
|
let completed: Vec<TaskSnapshot> = 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
|
|
// `<system-reminder>` 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::<OwnerSessionId>().map(|o| o.0.clone());
|
|
let state = res.get_or_default::<State<ReportedTaskCompletions>>();
|
|
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<String, kigi_tool_runtime::ToolError> {
|
|
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<TaskSnapshot>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl TerminalBackend for MockTerminal {
|
|
async fn run(
|
|
&self,
|
|
_: TerminalRunRequest,
|
|
) -> Result<TerminalRunResult, crate::computer::types::ComputerError> {
|
|
unimplemented!()
|
|
}
|
|
async fn run_background(
|
|
&self,
|
|
_: TerminalRunRequest,
|
|
) -> Result<BackgroundHandle, crate::computer::types::ComputerError> {
|
|
unimplemented!()
|
|
}
|
|
async fn kill_task(&self, _: &str) -> KillOutcome {
|
|
KillOutcome::NotFound
|
|
}
|
|
async fn get_task(&self, _: &str) -> Option<TaskSnapshot> {
|
|
None
|
|
}
|
|
async fn wait_for_completion(&self, _: &str, _: Option<Duration>) -> Option<TaskSnapshot> {
|
|
None
|
|
}
|
|
async fn list_tasks(&self) -> Vec<TaskSnapshot> {
|
|
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" `<system-reminder>` 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::<ReportedTaskCompletions>();
|
|
}
|
|
let backend: Arc<dyn TerminalBackend> = 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:?}"
|
|
);
|
|
}
|
|
}
|