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.
2609 lines
113 KiB
Rust
2609 lines
113 KiB
Rust
//! Subagent coordinator — spawns and tracks hidden child sessions.
|
|
//!
|
|
//! All subagent-specific types, tracking state, and orchestration logic live here.
|
|
//! `MvpAgent` only wires the channel and calls `handle_subagent_request()`.
|
|
//!
|
|
//! ## Design
|
|
//!
|
|
//! - `SubagentCoordinator` owns the active-subagent map (stored as a field on `MvpAgent`).
|
|
//! - `handle_subagent_request()` is a free async function that receives a
|
|
//! `SubagentSpawnContext` parameter bag — it never borrows `MvpAgent`.
|
|
//! - Child sessions share the parent's hunk tracker, filesystem, terminal, and env
|
|
//! so that edits, bash commands, and file reads go through the same backends.
|
|
#![allow(unused_imports)]
|
|
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
|
use crate::session::{
|
|
self, SessionCommand, SessionHandle, SessionThread,
|
|
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
|
fs_watch::FsWatchCapabilities,
|
|
info::Info as SessionInfo,
|
|
};
|
|
use crate::terminal::AsyncTerminalRunner;
|
|
use crate::tools::ToolContext;
|
|
use agent_client_protocol as acp;
|
|
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
|
use kigi_hunk_tracker::HunkTrackerHandle;
|
|
use kigi_tools::implementations::kigi::task::types::*;
|
|
use kigi_workspace::file_system::AsyncFileSystem;
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
#[cfg(test)]
|
|
use std::sync::OnceLock;
|
|
use tokio::sync::{Notify, mpsc, oneshot};
|
|
use tokio_util::sync::CancellationToken;
|
|
mod coordinator_lifecycle;
|
|
mod coordinator_query;
|
|
mod handle_request;
|
|
pub(crate) use handle_request::handle_subagent_request;
|
|
/// How the child session's initial context was bootstrapped.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum InitialContextSource {
|
|
/// Fresh session — no inherited history.
|
|
New,
|
|
/// Parent history as `<background_context>` (harness-only chat-prefix fork).
|
|
Forked,
|
|
/// Resumed from a previously completed peer subagent. The child inherits
|
|
/// the source's raw transcript, tool state, and model. System prompt and
|
|
/// prompt context are freshly rendered from the current agent definition.
|
|
Resumed,
|
|
}
|
|
/// Tracks a single active subagent for progress polling and cleanup.
|
|
pub(crate) struct SubagentTracker {
|
|
pub subagent_id: String,
|
|
pub parent_session_id: String,
|
|
pub parent_prompt_id: Option<String>,
|
|
pub child_session_id: acp::SessionId,
|
|
pub subagent_type: String,
|
|
pub persona: Option<String>,
|
|
pub description: String,
|
|
pub started_at: std::time::Instant,
|
|
pub child_handle: SessionHandle,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub child_thread: SessionThread,
|
|
pub cancel_token: CancellationToken,
|
|
/// ID of the source subagent this session was resumed from.
|
|
pub resumed_from: Option<String>,
|
|
/// Effective cwd used by the child session. Retained so
|
|
/// `move_to_completed` can propagate it to `CompletedSubagent`.
|
|
pub child_cwd: String,
|
|
/// Worktree path if the child used `isolation=worktree`.
|
|
pub worktree_path: Option<PathBuf>,
|
|
/// Effective model ID used by the child session.
|
|
pub effective_model_id: String,
|
|
/// Whether the subagent was launched with `run_in_background: true`.
|
|
pub run_in_background: bool,
|
|
/// Mirrors `SubagentRequest::surface_completion`.
|
|
pub surface_completion: bool,
|
|
/// Set when a `block=true` waiter consumed this subagent's result.
|
|
pub block_waited: bool,
|
|
/// Set when the model explicitly killed this subagent via the kill tool.
|
|
pub explicitly_killed: bool,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub color: Option<kigi_agent::config::AgentColor>,
|
|
}
|
|
/// Captured parent-side tier inputs for resolving
|
|
/// `auto_compact_threshold_percent` once the subagent's actual model id is
|
|
/// known. Stored on [`SubagentSpawnContext`] so the resolver can run at
|
|
/// spawn time and the per-model lookup honors the SUBAGENT's model rather
|
|
/// than the parent's.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct AutoCompactThresholdTiers {
|
|
/// `cfg.session.auto_compact_threshold_percent` (user global TOML).
|
|
pub user_session: Option<u8>,
|
|
/// Subset of `cfg.config_models` whose `auto_compact_threshold_percent`
|
|
/// is set, keyed by the model entry's id (the table key in
|
|
/// `[model.<id>]`). Looked up by the subagent's resolved model id at
|
|
/// spawn time so user per-model overrides for the subagent's model are
|
|
/// honored (not just the parent's).
|
|
pub user_per_model: std::collections::HashMap<String, u8>,
|
|
/// `cfg.remote_settings.auto_compact_threshold_percent` (GB global).
|
|
pub remote_global: Option<u8>,
|
|
}
|
|
impl AutoCompactThresholdTiers {
|
|
/// Slice the parent's `Config` into the four tier inputs we'll resolve
|
|
/// against later. Only fields relevant to the auto-compact threshold
|
|
/// are captured; the parent's `Config` is not held by reference.
|
|
pub fn capture(cfg: &crate::agent::config::Config) -> Self {
|
|
let user_per_model = cfg
|
|
.config_models
|
|
.iter()
|
|
.filter_map(|(k, v)| v.auto_compact_threshold_percent.map(|t| (k.clone(), t)))
|
|
.collect();
|
|
Self {
|
|
user_session: cfg.session.auto_compact_threshold_percent,
|
|
user_per_model,
|
|
remote_global: cfg
|
|
.remote_settings
|
|
.as_ref()
|
|
.and_then(|r| r.auto_compact_threshold_percent),
|
|
}
|
|
}
|
|
}
|
|
/// Everything the coordinator needs from MvpAgent to spawn a child session.
|
|
///
|
|
/// Avoids passing `&MvpAgent` (which would require the coordinator to know
|
|
/// about the full agent struct). Built by `MvpAgent::build_subagent_spawn_context()`.
|
|
pub(crate) struct SubagentSpawnContext {
|
|
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
|
|
pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub gateway: GatewaySender,
|
|
/// Parent's client-registered hooks, inherited so the subagent's tool calls hit the
|
|
/// same PreToolUse gate and its events fire the same observe hooks over the parent's
|
|
/// connection. Empty when the parent has none. Filled by the coordinator after the
|
|
/// context is built (an async snapshot from the parent session actor).
|
|
pub client_hooks: crate::extensions::hooks::ClientHooks,
|
|
pub sampling_config: kigi_sampler::SamplerConfig,
|
|
/// The staging auth header value propagated from the parent. Used
|
|
/// when materialising subagent `SamplerConfig`s for auth-flow tracking
|
|
/// and for `inject_url_derived_headers` in the construction helpers.
|
|
pub alpha_test_key: Option<String>,
|
|
pub auth_method_id: acp::AuthMethodId,
|
|
pub model_id: acp::ModelId,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub storage_mode: crate::config::StorageMode,
|
|
pub auth: Option<crate::auth::KimiAuth>,
|
|
pub parent_cwd: PathBuf,
|
|
pub parent_session_id: String,
|
|
pub yolo_mode: bool,
|
|
pub subagent_event_tx: mpsc::UnboundedSender<SubagentEvent>,
|
|
pub parent_depth: u32,
|
|
/// Inference idle timeout (secs), resolved from the parent's model config at spawn-context creation time.
|
|
pub inference_idle_timeout_secs: u64,
|
|
/// Tier inputs for resolving `auto_compact_threshold_percent` at
|
|
/// spawn time — once the subagent's actual model id is known.
|
|
/// Lazy because the subagent may be assigned a different model from
|
|
/// the parent (via `[subagents.models]` or `AgentDefinition.model`);
|
|
/// we want the resolver's per-model
|
|
/// tiers to be looked up against the SUBAGENT's model, not the
|
|
/// parent's. Call [`Self::resolve_auto_compact_threshold_percent`]
|
|
/// once the subagent's `effective_sampling_config.model` is known.
|
|
pub auto_compact_threshold_tiers: AutoCompactThresholdTiers,
|
|
/// Parent's hunk tracker handle — cheap Clone, backed by an mpsc channel
|
|
/// to the parent's HunkTrackerActor. Subagent edits are attributed to
|
|
/// the same hunk tracker so the parent sees all file changes.
|
|
pub hunk_tracker_handle: HunkTrackerHandle,
|
|
/// Parent's hunk-tracking gate, inherited so a disabled parent's subagent
|
|
/// also skips the per-event forward instead of paying it into a noop handle.
|
|
pub hunk_tracking_enabled: bool,
|
|
/// Parent's filesystem implementation (LocalFs or AcpSessionFs).
|
|
/// Shared so the child reads/writes the same working tree.
|
|
pub fs: Arc<dyn AsyncFileSystem>,
|
|
/// Parent's terminal runner — shared so bash commands run in the
|
|
/// same terminal environment (env vars, cwd, color settings).
|
|
pub terminal: Arc<dyn AsyncTerminalRunner>,
|
|
/// Parent's terminal backend — shared so background tasks, monitors, and
|
|
/// scheduled tasks survive subagent exit. When `Some`, the subagent session
|
|
/// reuses this backend instead of creating a new `LocalTerminalBackend`.
|
|
pub parent_terminal_backend: Option<Arc<dyn kigi_tools::computer::types::TerminalBackend>>,
|
|
/// Parent's notification handle for reparenting on subagent exit.
|
|
/// When a subagent exits, its surviving tasks (monitors, bg commands)
|
|
/// need their notification handles swapped to this so events route
|
|
/// to the parent's notification bridge.
|
|
pub parent_notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
|
/// Parent's scheduler handle. When `Some`, the subagent reuses the
|
|
/// parent's scheduler actor so scheduled tasks survive subagent exit.
|
|
pub parent_scheduler_handle:
|
|
Option<kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle>,
|
|
/// Parent's session environment variables (.envrc + color settings).
|
|
/// Shared so the child inherits the same env without re-loading.
|
|
pub session_env: Arc<HashMap<String, String>>,
|
|
/// Parent's memory config — shared so the child can access the same
|
|
/// cross-session memory store.
|
|
pub memory_config: Option<crate::config::MemoryConfig>,
|
|
/// Resolved sampling config for web_search.
|
|
pub web_search_config: kigi_tools::implementations::WebSearchConfig,
|
|
/// Resolved config for web fetch.
|
|
pub web_fetch_config: kigi_tools::implementations::kigi::web_fetch::WebFetchConfig,
|
|
/// Resolved config for the deploy service.
|
|
pub app_builder_deployer_config:
|
|
kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
|
/// Whether the write_file tool is enabled.
|
|
pub write_file_enabled: bool,
|
|
/// Whether goal mode (`/goal`) is enabled.
|
|
pub goal_enabled: bool,
|
|
/// Whether the `ask_user_question` tool is exposed to this subagent,
|
|
/// inherited from the parent session (see `build_subagent_spawn_context`).
|
|
pub ask_user_question_enabled: bool,
|
|
/// Parent session command channel. Carries lifecycle notifications the
|
|
/// parent persists (`SubagentSpawned` / `SubagentFinished`) and — when
|
|
/// goal mode is on — transient `SubagentProgress` ticks the parent
|
|
/// consumes for token accounting without persisting.
|
|
pub parent_cmd_tx: Option<mpsc::UnboundedSender<SessionCommand>>,
|
|
/// Parent session info — used to locate parent session directory.
|
|
pub parent_session_info: Option<SessionInfo>,
|
|
/// Subagent roles config for role-based config layering.
|
|
pub subagent_roles:
|
|
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentRole>,
|
|
/// Subagent personas config for persona/SOUL layering.
|
|
pub subagent_personas:
|
|
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentPersona>,
|
|
/// Pre-rendered persona IO summaries for the task tool description.
|
|
/// Threaded through to child sessions for recursive persona discovery.
|
|
pub persona_io_summaries: Vec<String>,
|
|
/// Parent session's ChatStateHandle — used to read the actual live
|
|
/// sampling config and credentials from the parent session actor (async).
|
|
/// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found.
|
|
pub parent_chat_state: Option<kigi_chat_state::ChatStateHandle>,
|
|
/// Parent session's resolved turn limit, for subagent inheritance.
|
|
pub parent_max_turns: Option<usize>,
|
|
/// All available models for resolving model IDs from overrides.
|
|
pub available_models: indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
|
|
/// Per-subagent model ID overrides from config.toml `[subagents.models]`.
|
|
pub subagent_model_overrides: std::collections::HashMap<String, String>,
|
|
/// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`.
|
|
/// Omitted agents default to enabled (`true`).
|
|
pub subagent_toggle: std::collections::HashMap<String, bool>,
|
|
/// Whether web search is force-disabled via `--disable-web-search`.
|
|
/// Inherited from the parent session.
|
|
pub disable_web_search: bool,
|
|
/// Whether the runtime turn-end TodoGate is force-enabled via
|
|
/// `--todo-gate`. Inherited from the parent session.
|
|
pub todo_gate: bool,
|
|
/// Remote settings snapshot from the parent session. Used to resolve
|
|
/// `ReminderPolicy.todo_gate` (CLI > remote > default) for the subagent.
|
|
pub remote_settings: Option<crate::util::config::RemoteSettings>,
|
|
/// Inherited `--laziness-debug-log <path>` from the parent session.
|
|
/// Subagent classifier fires append to the same log file. `None`
|
|
/// when the parent did not enable debug mode.
|
|
pub laziness_debug_log: Option<std::path::PathBuf>,
|
|
pub backend_tools_enabled: bool,
|
|
/// Whether tools should respect `.gitignore` patterns.
|
|
/// Inherited from the parent session.
|
|
pub respect_gitignore: bool,
|
|
/// Whether to enrich path-not-found errors with hints.
|
|
/// Inherited from the parent session.
|
|
pub path_not_found_hints: bool,
|
|
/// Plugin registry for plugin-aware agent lookup.
|
|
pub plugin_registry: Option<std::sync::Arc<kigi_agent::plugins::PluginRegistry>>,
|
|
/// Shared models manager for etag-triggered refresh.
|
|
pub models_manager: crate::agent::models::ModelsManager,
|
|
/// Pre-resolved file tool overrides (hashline vs standard) from the parent.
|
|
/// `None` means use the standard (default) file tools.
|
|
pub file_tool_overrides: Option<Vec<kigi_tools::registry::types::ToolConfig>>,
|
|
/// Parent session's agent config snapshot.
|
|
pub agent_config: Option<crate::agent::config::Config>,
|
|
pub hook_registry: Option<std::sync::Arc<kigi_hooks::discovery::HookRegistry>>,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub hook_workspace_root: String,
|
|
pub permission_handle: Option<kigi_workspace::permission::PermissionHandle>,
|
|
pub worktree_type: crate::util::config::WorktreeType,
|
|
pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
|
pub image_description_model: String,
|
|
/// Dual-mode workspace operations handle.
|
|
pub workspace_ops: kigi_workspace::WorkspaceOps,
|
|
pub auth_manager: std::sync::Arc<crate::auth::AuthManager>,
|
|
/// The parent SessionActor's live
|
|
/// `Auth401AttributionCallback`, captured at spawn time.
|
|
/// Subagents inherit this so the child's `OaiCompatClient` 401
|
|
/// sites emit attribution under the parent's session id, joined
|
|
/// with the parent's live `AuthManager`.
|
|
///
|
|
/// Note: this is the load-bearing source of the inherited
|
|
/// callback. Reading from `ctx.sampling_config.attribution_callback`
|
|
/// would not work because the baseline `MvpAgent.sampling_config`
|
|
/// goes through `agent/config.rs::sampling_config_for_model`
|
|
/// which always sets that field to `None`.
|
|
pub attribution_callback: Option<kigi_sampler::SharedAttributionCallback>,
|
|
/// Parent session's agent name (e.g. "kigi").
|
|
pub parent_agent_name: Option<String>,
|
|
/// `agent_type` of the parent's current model — the harness-flavor fallback
|
|
/// when `parent_agent_name` is not a recognized harness, e.g. a custom
|
|
/// client profile keeps its own name but runs a strict-harness model.
|
|
/// `None` when the model is not in the catalog.
|
|
pub parent_model_agent_type: Option<String>,
|
|
pub allowed_subagent_types: Option<Vec<String>>,
|
|
/// Parent's MCP server configs for resolving named references in agent mcpServers.
|
|
///
|
|
/// NOTE: This is a snapshot from `SessionHandle` (populated at spawn_session_actor
|
|
/// time). Servers added later via `UpdateMcpServers` (managed MCPs, plugin reload)
|
|
/// will not appear here. Named references only resolve against the initial config.
|
|
pub parent_mcp_configs: Vec<agent_client_protocol::McpServer>,
|
|
/// Snapshot of the parent session's MCP client pool at spawn time.
|
|
pub parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
|
|
/// Snapshot of the parent session's resolved tool schema at spawn time.
|
|
/// `Some` only when a fork parent's actor answered; threaded to the child so a
|
|
/// verbatim mirror-fork sends the parent's exact tool prefix for cache reuse.
|
|
pub parent_tool_snapshot: Option<Vec<kigi_sampling_types::ToolSpec>>,
|
|
/// Pre-discovered skills from the parent session, captured at spawn time.
|
|
pub parent_skills: Option<Vec<kigi_tools::implementations::skills::types::SkillInfo>>,
|
|
/// Parent's skills config for the child's SkillManager.
|
|
pub parent_skills_config: kigi_agent::prompt::skills::SkillsConfig,
|
|
/// Parent's resolved vendor-compat config, inherited by the child so its
|
|
/// skills / rules / AGENTS.md discovery honors the same vendor toggles.
|
|
pub parent_compat: kigi_tools::types::compat::CompatConfig,
|
|
/// Shared set of IDs delivered via auto-wake synthetic prompts.
|
|
pub auto_wake_delivered: Option<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
|
|
/// Resolved name of the `BackgroundTaskAction` tool in the parent's toolset.
|
|
pub task_output_tool_name: String,
|
|
/// Whether auto-wake is enabled. When `false`, subagent completions
|
|
/// are not injected as synthetic prompts.
|
|
pub auto_wake_enabled: bool,
|
|
/// Parent's live goal-loop gate (shared `Arc`). When set, the subagent
|
|
/// auto-wake synthetic prompt is suppressed so an async completion wake
|
|
/// doesn't derail the parent mid-`/goal`; surfaces 2/3 still drain it.
|
|
pub goal_loop_active: Arc<std::sync::atomic::AtomicBool>,
|
|
/// Parent's `blocking_wait_depth` (same `Arc`). A foreground spawn holds a
|
|
/// `BlockingWaitGuard` on it for the blocking await so `queue_input` routes
|
|
/// a prompt sent during the wait onto send-now; never for background spawns.
|
|
pub parent_blocking_wait_depth: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
impl SubagentSpawnContext {
|
|
/// Check if a subagent is enabled via the toggle config.
|
|
/// Returns `true` if the agent is not in the toggle map (default enabled).
|
|
fn is_subagent_enabled(&self, name: &str) -> bool {
|
|
self.subagent_toggle.get(name).copied().unwrap_or(true)
|
|
}
|
|
/// Resolve `auto_compact_threshold_percent` for the subagent's actual
|
|
/// model id (the one selected by `resolve_subagent_sampling_config`,
|
|
/// not the parent's). Walks the same precedence as the main session's
|
|
/// resolver: env > user [model.<id>] > user [session] > GB per-model
|
|
/// > GB global > 85.
|
|
///
|
|
/// The GB per-model tier is read from `available_models` (the same
|
|
/// catalog used to pick the subagent's `SamplerConfig`); user TOML and
|
|
/// GB global tiers are sourced from the parent's snapshot captured at
|
|
/// spawn-context build time.
|
|
pub fn resolve_auto_compact_threshold_percent(&self, subagent_model_id: &str) -> u8 {
|
|
let gb_per_model =
|
|
crate::agent::config::find_model_by_id(&self.available_models, subagent_model_id)
|
|
.and_then(|e| e.info.auto_compact_threshold_percent);
|
|
crate::util::config::resolve_auto_compact_threshold_percent_from_tiers(
|
|
self.auto_compact_threshold_tiers
|
|
.user_per_model
|
|
.get(subagent_model_id)
|
|
.copied(),
|
|
self.auto_compact_threshold_tiers.user_session,
|
|
gb_per_model,
|
|
self.auto_compact_threshold_tiers.remote_global,
|
|
)
|
|
}
|
|
/// Bind a spawned subagent by the parent session's `--tools`/
|
|
/// `--disallowed-tools`/`--permission-mode` restrictions.
|
|
fn apply_session_cli_overrides(&self, def: &mut kigi_agent::config::AgentDefinition) {
|
|
if let Some(ref cfg) = self.agent_config {
|
|
cfg.cli_agent_overrides.apply_to_subagent_definition(def);
|
|
}
|
|
}
|
|
/// Subagent verbatim-input flag, mirroring `Config::resolve_compaction_verbatim_input` (env > config > remote settings > default `true`).
|
|
pub fn resolve_compaction_verbatim_input(&self) -> bool {
|
|
crate::agent::config::BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
|
|
.config(
|
|
self.agent_config
|
|
.as_ref()
|
|
.and_then(|c| c.features.compaction_verbatim_input),
|
|
)
|
|
.feature_flag(
|
|
self.remote_settings
|
|
.as_ref()
|
|
.and_then(|r| r.compaction_verbatim_input),
|
|
)
|
|
.default(true)
|
|
.resolve()
|
|
.value
|
|
}
|
|
/// Whether a completed subagent's worktree is snapshotted into a durable ref
|
|
/// and its directory deleted. Resolution mirrors the other subagent gates
|
|
/// (env > config > remote settings > default). Default `false` so it ships dark;
|
|
/// `managed_config.toml` `[features] subagent_worktree_snapshot` is the
|
|
/// per-deployment rollout lever.
|
|
pub fn resolve_subagent_worktree_snapshot_enabled(&self) -> bool {
|
|
crate::agent::config::BoolFlag::env("KIGI_SUBAGENT_WORKTREE_SNAPSHOT")
|
|
.config(
|
|
self.agent_config
|
|
.as_ref()
|
|
.and_then(|c| c.features.subagent_worktree_snapshot),
|
|
)
|
|
.feature_flag(
|
|
self.remote_settings
|
|
.as_ref()
|
|
.and_then(|r| r.subagent_worktree_snapshot_enabled),
|
|
)
|
|
.default(false)
|
|
.resolve()
|
|
.value
|
|
}
|
|
/// Per-tool params for the child's spawn. The ask_user_question timeout is
|
|
/// session-level config, so it is resolved from the same tiers as the
|
|
/// parent (requirements/env/user/managed from disk; remote from the
|
|
/// parent's snapshot) and follows the session into subagents. Bash stays
|
|
/// on tool defaults, as before that knob existed.
|
|
pub fn resolve_tool_params_json(
|
|
&self,
|
|
) -> crate::session::agent_rebuild::ResolvedToolParamsJson {
|
|
let params = crate::util::config::resolve_ask_user_question_params_from_disk(
|
|
self.remote_settings.as_ref(),
|
|
);
|
|
crate::session::agent_rebuild::ResolvedToolParamsJson {
|
|
bash: None,
|
|
ask_user_question: match serde_json::to_value(params) {
|
|
Ok(serde_json::Value::Object(map)) => Some(map),
|
|
_ => None,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
/// A completed subagent entry retained for `TaskOutputTool` polling
|
|
/// and `resume_from` resolution.
|
|
pub(crate) struct CompletedSubagent {
|
|
pub subagent_id: String,
|
|
pub parent_session_id: String,
|
|
pub parent_prompt_id: Option<String>,
|
|
pub child_session_id: String,
|
|
pub description: String,
|
|
pub subagent_type: String,
|
|
pub persona: Option<String>,
|
|
pub started_at: std::time::Instant,
|
|
/// When the subagent moved to the completed map. Used for TTL eviction.
|
|
pub completed_at: std::time::Instant,
|
|
pub result: SubagentResult,
|
|
/// ID of the source subagent this session was resumed from.
|
|
pub resumed_from: Option<String>,
|
|
/// Effective cwd used by the child session (worktree path or parent cwd).
|
|
/// Required to reconstruct `SessionInfo` for `resume_from`.
|
|
pub child_cwd: String,
|
|
/// Path to the isolated worktree, if the child used `isolation=worktree`.
|
|
pub worktree_path: Option<PathBuf>,
|
|
/// Durable git ref snapshotting the worktree's working state, if captured.
|
|
pub snapshot_ref: Option<String>,
|
|
/// Effective model ID used by the child session.
|
|
pub effective_model_id: String,
|
|
/// Set when a `block=true` waiter consumed this subagent's result.
|
|
pub block_waited: bool,
|
|
/// Set when the model explicitly killed this subagent via the kill tool.
|
|
pub explicitly_killed: bool,
|
|
}
|
|
/// Lightweight entry for subagents that have been requested but are still
|
|
/// initializing (creating worktree, resolving config, spawning session).
|
|
/// Promoted to a full `SubagentTracker` once the child session is ready.
|
|
pub(crate) struct PendingSubagent {
|
|
pub subagent_id: String,
|
|
pub subagent_type: String,
|
|
pub description: String,
|
|
pub persona: Option<String>,
|
|
pub parent_prompt_id: Option<String>,
|
|
pub parent_session_id: String,
|
|
pub started_at: std::time::Instant,
|
|
pub run_in_background: bool,
|
|
/// Mirrors `SubagentRequest::surface_completion`.
|
|
pub surface_completion: bool,
|
|
#[expect(
|
|
dead_code,
|
|
reason = "unused in production; remove expect when wired or delete the item"
|
|
)]
|
|
pub color: Option<kigi_agent::config::AgentColor>,
|
|
/// Spawn-future cancel token; firing it aborts the spawn at the promote
|
|
/// checkpoint and emits a cancelled `SubagentFinished`.
|
|
pub cancel_token: CancellationToken,
|
|
}
|
|
/// Parameter bag for `SubagentCoordinator::record_failure_completion`.
|
|
struct FailureCompletion<'a> {
|
|
subagent_id: String,
|
|
subagent_type: String,
|
|
description: String,
|
|
parent_prompt_id: Option<String>,
|
|
parent_session_id: String,
|
|
persona: Option<String>,
|
|
started_at: std::time::Instant,
|
|
error: &'a str,
|
|
surface_completion: bool,
|
|
/// Terminal status `"cancelled"` rather than `"failed"` (pending subagent
|
|
/// killed mid-initialization).
|
|
cancelled: bool,
|
|
}
|
|
/// Shared reply slot for one live blocking `SubagentQueryRequest`.
|
|
///
|
|
/// The query poll loop (`subagent_coordinator.rs`) parks the oneshot sender
|
|
/// here and delivers through it; the completion handler reads it to verify —
|
|
/// at auto-wake decision time — whether a blocking waiter can still receive
|
|
/// the result. `Rc` is safe: the coordinator and all its users live on the
|
|
/// agent's single-threaded `LocalSet` (see `spawn_local` in
|
|
/// `start_subagent_coordinator`).
|
|
pub(crate) type BlockWaitSlot =
|
|
std::rc::Rc<std::cell::RefCell<Option<oneshot::Sender<Option<SubagentSnapshot>>>>>;
|
|
/// Owns the active-subagent map and completed-result cache.
|
|
/// Stored as a field on `MvpAgent`.
|
|
///
|
|
/// Methods on this struct contain all the orchestration logic so
|
|
/// `mvp_agent/mod.rs` stays thin.
|
|
pub(crate) struct SubagentCoordinator {
|
|
/// subagent_id → pending entry (initializing subagents)
|
|
pending: HashMap<String, PendingSubagent>,
|
|
/// subagent_id → tracker (running subagents)
|
|
active: HashMap<String, SubagentTracker>,
|
|
/// subagent_id → completed result (finished subagents, kept for polling)
|
|
completed: HashMap<String, CompletedSubagent>,
|
|
/// Notified each time a subagent moves to the completed state.
|
|
/// Multi-wait infrastructure subscribes via `completion_notify()`.
|
|
completion_notify: Arc<Notify>,
|
|
/// Completions buffered for between-turn delivery.
|
|
pending_completions: Vec<SubagentCompletionSummary>,
|
|
/// Whether the model's turn is currently active. Shared with the session
|
|
/// via `Arc` so it can be toggled at turn boundaries from `handle_prompt`.
|
|
is_turn_active: Arc<std::sync::atomic::AtomicBool>,
|
|
/// Gauge of initializing + running subagents (`pending.len() +
|
|
/// active.len()`), kept in sync by [`Self::sync_running_gauge`]. Read by
|
|
/// [`crate::agent::activity::AgentActivity::is_busy`] to defer leader
|
|
/// auto-update shutdown while subagents are in flight.
|
|
running_gauge: Arc<std::sync::atomic::AtomicUsize>,
|
|
/// subagent_id → live blocking-query reply slots. Registered together
|
|
/// with `block_waited` so the completion handler can verify at decision
|
|
/// time that a waiter is actually able to receive the result (a
|
|
/// cancelled turn drops the receiver; the sticky flag alone would
|
|
/// wrongly suppress the completion auto-wake).
|
|
block_wait_slots: HashMap<String, Vec<BlockWaitSlot>>,
|
|
/// Prompts whose subagent usage landed session-only or failed to apply.
|
|
/// A report-level incomplete signal only: not a token sink, and it never
|
|
/// marks ledgers by itself (a true apply-miss marks them at fold time).
|
|
/// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model.
|
|
subagent_usage_not_applied_prompts: std::collections::HashSet<String>,
|
|
}
|
|
fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary {
|
|
ActiveSubagentSummary {
|
|
subagent_id: t.subagent_id.clone(),
|
|
subagent_type: t.subagent_type.clone(),
|
|
description: t.description.clone(),
|
|
elapsed_ms: t.started_at.elapsed().as_millis() as u64,
|
|
}
|
|
}
|
|
/// Result of `SubagentCoordinator::lookup()`.
|
|
///
|
|
/// Separates the synchronous map lookup from the async signals query so
|
|
/// callers can drop the `RefCell<SubagentCoordinator>` borrow before
|
|
/// awaiting.
|
|
pub(crate) enum SnapshotLookup {
|
|
/// Subagent is finished — snapshot is fully resolved.
|
|
Ready(SubagentSnapshot),
|
|
/// Subagent is still running — caller must await `resolve_snapshot()`
|
|
/// to populate the live progress fields.
|
|
NeedsSignals(RunningSnapshotSeed),
|
|
}
|
|
/// Metadata extracted synchronously from an active `SubagentTracker`,
|
|
/// plus a cloned `SessionSignalsHandle` for the async progress query.
|
|
pub(crate) struct RunningSnapshotSeed {
|
|
pub(crate) subagent_id: String,
|
|
pub(crate) description: String,
|
|
pub(crate) subagent_type: String,
|
|
pub(crate) started_at_epoch_ms: u64,
|
|
pub(crate) duration_ms: u64,
|
|
pub(crate) persona: Option<String>,
|
|
pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle,
|
|
}
|
|
/// Resolve an `Option<SnapshotLookup>` into `Option<SubagentSnapshot>`.
|
|
///
|
|
/// - `None` → `None` (subagent not found).
|
|
/// - `Ready` → returns the completed snapshot unchanged.
|
|
/// - `NeedsSignals` → awaits `signals_handle.snapshot()` to populate the
|
|
/// `Running { ... }` fields.
|
|
///
|
|
/// This is the **single** async helper used by both the immediate query
|
|
/// path and the `block=true` polling loop.
|
|
pub(crate) async fn resolve_snapshot(lookup: Option<SnapshotLookup>) -> Option<SubagentSnapshot> {
|
|
match lookup? {
|
|
SnapshotLookup::Ready(snap) => Some(snap),
|
|
SnapshotLookup::NeedsSignals(seed) => {
|
|
let signals = seed.signals_handle.snapshot().await.unwrap_or_default();
|
|
Some(SubagentSnapshot {
|
|
subagent_id: seed.subagent_id,
|
|
description: seed.description,
|
|
subagent_type: seed.subagent_type,
|
|
started_at_epoch_ms: seed.started_at_epoch_ms,
|
|
duration_ms: seed.duration_ms,
|
|
persona: seed.persona,
|
|
status: SubagentSnapshotStatus::Running {
|
|
turn_count: signals.turn_count,
|
|
tool_call_count: signals.tool_call_count,
|
|
tokens_used: signals.context_tokens_used,
|
|
context_window_tokens: signals.context_window_tokens,
|
|
context_usage_pct: signals.context_window_usage,
|
|
tools_used: signals.tools_used,
|
|
error_count: signals.error_count,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
/// Check whether a resolved snapshot is still in the `Running` state.
|
|
pub(crate) fn is_running(snap: &SubagentSnapshot) -> bool {
|
|
matches!(
|
|
snap.status,
|
|
SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing
|
|
)
|
|
}
|
|
/// Seed for one running subagent returned by
|
|
/// `SubagentCoordinator::list_running_for_parent()`.
|
|
///
|
|
/// Includes identity fields not present on the single-item
|
|
/// `RunningSnapshotSeed` (`parent_session_id`, `child_session_id`).
|
|
pub(crate) struct RunningSubagentListSeed {
|
|
pub(crate) subagent_id: String,
|
|
pub(crate) parent_session_id: String,
|
|
pub(crate) child_session_id: String,
|
|
pub(crate) subagent_type: String,
|
|
pub(crate) description: String,
|
|
pub(crate) started_at_epoch_ms: u64,
|
|
pub(crate) duration_ms: u64,
|
|
pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle,
|
|
}
|
|
/// Resolved running subagent with live progress from `SessionSignals`.
|
|
///
|
|
/// Produced by `resolve_running_list()` and consumed by the ACP extension
|
|
/// layer for DTO conversion.
|
|
pub(crate) struct ResolvedRunningSubagent {
|
|
pub(crate) subagent_id: String,
|
|
pub(crate) parent_session_id: String,
|
|
pub(crate) child_session_id: String,
|
|
pub(crate) subagent_type: String,
|
|
pub(crate) description: String,
|
|
pub(crate) started_at_epoch_ms: u64,
|
|
pub(crate) duration_ms: u64,
|
|
pub(crate) turn_count: u32,
|
|
pub(crate) tool_call_count: u32,
|
|
pub(crate) tokens_used: u64,
|
|
pub(crate) context_window_tokens: u64,
|
|
pub(crate) context_usage_pct: u8,
|
|
pub(crate) tools_used: Vec<String>,
|
|
pub(crate) error_count: u32,
|
|
}
|
|
/// Resolve a list of running subagent seeds concurrently into
|
|
/// `ResolvedRunningSubagent` values with live progress data.
|
|
///
|
|
/// Uses `join_all` to pull signal snapshots in parallel rather than
|
|
/// serially awaiting each handle.
|
|
pub(crate) async fn resolve_running_list(
|
|
seeds: Vec<RunningSubagentListSeed>,
|
|
) -> Vec<ResolvedRunningSubagent> {
|
|
let futs = seeds.into_iter().map(|seed| async move {
|
|
let signals = seed.signals_handle.snapshot().await.unwrap_or_default();
|
|
ResolvedRunningSubagent {
|
|
subagent_id: seed.subagent_id,
|
|
parent_session_id: seed.parent_session_id,
|
|
child_session_id: seed.child_session_id,
|
|
subagent_type: seed.subagent_type,
|
|
description: seed.description,
|
|
started_at_epoch_ms: seed.started_at_epoch_ms,
|
|
duration_ms: seed.duration_ms,
|
|
turn_count: signals.turn_count,
|
|
tool_call_count: signals.tool_call_count,
|
|
tokens_used: signals.context_tokens_used,
|
|
context_window_tokens: signals.context_window_tokens,
|
|
context_usage_pct: signals.context_window_usage,
|
|
tools_used: signals.tools_used,
|
|
error_count: signals.error_count,
|
|
}
|
|
});
|
|
futures::future::join_all(futs).await
|
|
}
|
|
use kigi_subagent_resolution::ResumeSourceData;
|
|
/// Resume provenance metadata for a subagent.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct SubagentProvenance {
|
|
pub(crate) fork_parent_prompt_id: Option<String>,
|
|
/// ID of the source subagent this session was resumed from.
|
|
pub(crate) resumed_from: Option<String>,
|
|
}
|
|
/// Convert a `std::time::Instant` to approximate epoch milliseconds.
|
|
///
|
|
/// `Instant` has no absolute epoch, so we compute the offset from
|
|
/// `SystemTime::now()` at the time of the call. This is approximate
|
|
/// (a few ms of drift) but sufficient for display purposes.
|
|
fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 {
|
|
let now_instant = std::time::Instant::now();
|
|
let now_system = std::time::SystemTime::now();
|
|
let elapsed_since_instant = now_instant.saturating_duration_since(instant);
|
|
let system_at_instant = now_system
|
|
.checked_sub(elapsed_since_instant)
|
|
.unwrap_or(now_system);
|
|
system_at_instant
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64
|
|
}
|
|
use kigi_subagent_resolution::resolve_effective_overrides;
|
|
/// Resolve the sampling config and model ID for a subagent.
|
|
///
|
|
/// Subagents inherit the parent session's model by default. Only an
|
|
/// EXPLICIT per-agent pin can override that inheritance; there is no global
|
|
/// default model and no parent-model gate. Precedence (highest to lowest):
|
|
///
|
|
/// 1. `config.toml [subagents.models].{agent_name}` override, if it
|
|
/// resolves to a known model. Applies unconditionally.
|
|
///
|
|
/// 2. `AgentDefinition.model = Override(id)`, if it resolves to a known
|
|
/// model. Applies unconditionally.
|
|
///
|
|
/// 3. Inherit the parent session's actual live sampling config (from
|
|
/// `ChatStateHandle`).
|
|
///
|
|
/// Both explicit pins apply regardless of which model the parent is on. If a
|
|
/// pin references an unknown model it is ignored (with a `tracing::warn!`)
|
|
/// and resolution falls through to the next priority.
|
|
///
|
|
/// NOTE: the persona/role/runtime override (`effective_runtime.model`) is
|
|
/// applied by the caller (`handle_subagent_request`) BEFORE this function
|
|
/// runs, so it is not handled here.
|
|
///
|
|
/// NOTE: `agent_type` and `use_concise` on the resolved model are
|
|
/// intentionally ignored. Subagent prompt/toolset is always determined by
|
|
/// the `AgentDefinition`, not the model. See design spec
|
|
/// "Behavioral Rules section 3".
|
|
async fn resolve_subagent_sampling_config(
|
|
agent_name: &str,
|
|
agent_model: &kigi_agent::config::ModelOverride,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
|
|
use kigi_agent::config::ModelOverride;
|
|
let (parent_config, parent_mid) = read_parent_sampling_config(ctx).await;
|
|
let try_pin = |model_id: &str, source: &'static str, unknown_msg: &'static str| {
|
|
match resolve_model_override_to_config(model_id, ctx) {
|
|
Some((config, canonical_id)) => {
|
|
log_subagent_model_resolution(
|
|
agent_name,
|
|
source,
|
|
&config,
|
|
&canonical_id,
|
|
&parent_config,
|
|
);
|
|
Some((config, canonical_id))
|
|
}
|
|
None => {
|
|
tracing::warn!(agent = agent_name, model_id, "{unknown_msg}");
|
|
None
|
|
}
|
|
}
|
|
};
|
|
if let Some(model_id) = ctx.subagent_model_overrides.get(agent_name)
|
|
&& let Some(resolved) = try_pin(
|
|
model_id,
|
|
"config_override",
|
|
"Subagent model override references unknown model, falling through to inherit",
|
|
)
|
|
{
|
|
return resolved;
|
|
}
|
|
if let ModelOverride::Override(model_id) = agent_model
|
|
&& let Some(resolved) = try_pin(
|
|
model_id,
|
|
"agent_definition",
|
|
"Agent definition model references unknown model, falling through to inherit",
|
|
)
|
|
{
|
|
return resolved;
|
|
}
|
|
log_subagent_model_resolution(
|
|
agent_name,
|
|
"inherit_parent",
|
|
&parent_config,
|
|
&parent_mid,
|
|
&parent_config,
|
|
);
|
|
(parent_config, parent_mid)
|
|
}
|
|
/// Resolve a subagent's effective sampling config + model id, honoring the
|
|
/// model-resolution precedence (Key Decision #16).
|
|
///
|
|
/// An explicit `runtime_override_model` — the goal role model or a persona
|
|
/// override carried on `effective_runtime.model` — is resolved HERE, BEFORE
|
|
/// [`resolve_subagent_sampling_config`] (where the user `[subagents.models]`
|
|
/// pin and `AgentDefinition.model` apply). So a goal/persona override WINS
|
|
/// over a user per-agent pin. An override that does not resolve to a known
|
|
/// model warns and falls through to the pin path; `None` (inherit) hands
|
|
/// precedence back to the pin path entirely (pin > agent-def > inherit).
|
|
///
|
|
/// Extracted from `handle_subagent_request` so the precedence is unit-testable
|
|
/// without spawning a child session.
|
|
async fn resolve_effective_model_config(
|
|
runtime_override_model: Option<&str>,
|
|
subagent_type: &str,
|
|
definition_model: &kigi_agent::config::ModelOverride,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
|
|
if let Some(model_id) = runtime_override_model {
|
|
if let Some(resolved) = resolve_model_override_to_config(model_id, ctx) {
|
|
return resolved;
|
|
}
|
|
tracing::warn!(
|
|
model_id,
|
|
"Runtime model override references unknown model, falling through"
|
|
);
|
|
}
|
|
resolve_subagent_sampling_config(subagent_type, definition_model, ctx).await
|
|
}
|
|
/// Truncate an API key to a safe prefix for logging.
|
|
fn key_prefix(key: &Option<String>) -> String {
|
|
match key {
|
|
Some(k) => {
|
|
let len = k.len().min(8);
|
|
k[..len].to_string()
|
|
}
|
|
None => "<none>".to_string(),
|
|
}
|
|
}
|
|
/// Emit a unified log entry recording which model and credentials a subagent
|
|
/// resolved to, and how they compare to the parent's.
|
|
fn log_subagent_model_resolution(
|
|
agent_name: &str,
|
|
priority: &str,
|
|
resolved: &kigi_sampler::SamplerConfig,
|
|
resolved_id: &acp::ModelId,
|
|
parent: &kigi_sampler::SamplerConfig,
|
|
) {
|
|
let child_key = key_prefix(&resolved.api_key);
|
|
let parent_key = key_prefix(&parent.api_key);
|
|
let keys_match = resolved.api_key == parent.api_key;
|
|
kigi_log::unified_log::debug(
|
|
"subagent model resolved",
|
|
None,
|
|
Some(serde_json::json!(
|
|
{ "agent" : agent_name, "priority" : priority, "child_model" :
|
|
resolved_id.0.as_ref(), "child_base_url" : & resolved.base_url,
|
|
"child_key_prefix" : child_key, "parent_model" : & parent.model,
|
|
"parent_base_url" : & parent.base_url, "parent_key_prefix" : parent_key,
|
|
"keys_match" : keys_match, }
|
|
)),
|
|
);
|
|
}
|
|
/// Read the parent session's actual current sampling config.
|
|
///
|
|
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
|
|
/// to the baseline on `SubagentSpawnContext` if the actor is unavailable.
|
|
/// The returned [`acp::ModelId`] is the parent session catalog id (`ctx.model_id`),
|
|
/// not the process-global default or chat-state routing slug.
|
|
async fn read_parent_sampling_config(
|
|
ctx: &SubagentSpawnContext,
|
|
) -> (kigi_sampler::SamplerConfig, acp::ModelId) {
|
|
if let Some(ref chat_state) = ctx.parent_chat_state {
|
|
if let Some(cfg) = chat_state.get_sampling_config().await {
|
|
let creds = chat_state.get_credentials().await;
|
|
let mut extra_headers = cfg.extra_headers;
|
|
crate::agent::config::inject_url_derived_headers(
|
|
&mut extra_headers,
|
|
creds.alpha_test_key.as_deref(),
|
|
&cfg.base_url,
|
|
);
|
|
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
|
|
.map(|r| r.auth_scheme)
|
|
.unwrap_or_default();
|
|
let inherited = kigi_sampler::SamplerConfig {
|
|
api_key: creds.api_key,
|
|
base_url: cfg.base_url,
|
|
model: cfg.model.clone(),
|
|
max_completion_tokens: cfg.max_completion_tokens,
|
|
temperature: cfg.temperature,
|
|
top_p: cfg.top_p,
|
|
api_backend: cfg.api_backend,
|
|
auth_scheme,
|
|
extra_headers,
|
|
context_window: cfg.context_window.get(),
|
|
reasoning_effort: cfg.reasoning_effort,
|
|
force_http1: false,
|
|
max_retries: None,
|
|
stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false),
|
|
idle_timeout_secs: None,
|
|
origin_client: ctx.sampling_config.origin_client.clone(),
|
|
attribution_callback: ctx.attribution_callback.clone(),
|
|
bearer_resolver: None,
|
|
supports_backend_search: ctx
|
|
.models_manager
|
|
.model_supports_backend_search(ctx.model_id.0.as_ref()),
|
|
compactions_remaining: ctx
|
|
.models_manager
|
|
.model_compactions_remaining(ctx.model_id.0.as_ref()),
|
|
compaction_at_tokens: ctx
|
|
.models_manager
|
|
.model_compaction_at_tokens(ctx.model_id.0.as_ref()),
|
|
doom_loop_recovery: ctx.sampling_config.doom_loop_recovery,
|
|
header_injector: ctx.sampling_config.header_injector.clone(),
|
|
};
|
|
let model_id = ctx.model_id.clone();
|
|
let global_model_id = ctx.models_manager.current_model_id();
|
|
kigi_log::unified_log::debug(
|
|
"subagent read parent config (live)",
|
|
None,
|
|
Some(serde_json::json!(
|
|
{ "parent_model" : & inherited.model, "parent_base_url" : &
|
|
inherited.base_url, "parent_key_prefix" : key_prefix(& inherited
|
|
.api_key), "session_model_id" : model_id.0.as_ref(),
|
|
"global_model_id" : global_model_id.0.as_ref(), "source" :
|
|
"chat_state", }
|
|
)),
|
|
);
|
|
return (inherited, model_id);
|
|
}
|
|
tracing::warn!(
|
|
"Parent chat state actor returned None for sampling config, \
|
|
falling back to spawn context baseline"
|
|
);
|
|
}
|
|
kigi_log::unified_log::warn(
|
|
"subagent read parent config (fallback)",
|
|
None,
|
|
Some(serde_json::json!(
|
|
{ "parent_model" : & ctx.sampling_config.model, "parent_base_url" : & ctx
|
|
.sampling_config.base_url, "parent_key_prefix" : key_prefix(& ctx
|
|
.sampling_config.api_key), "source" : "spawn_context_baseline",
|
|
"has_chat_state" : ctx.parent_chat_state.is_some(), }
|
|
)),
|
|
);
|
|
let mut fallback = ctx.sampling_config.clone();
|
|
fallback.supports_backend_search = ctx
|
|
.models_manager
|
|
.model_supports_backend_search(ctx.model_id.0.as_ref());
|
|
fallback.compactions_remaining = ctx
|
|
.models_manager
|
|
.model_compactions_remaining(ctx.model_id.0.as_ref());
|
|
fallback.compaction_at_tokens = ctx
|
|
.models_manager
|
|
.model_compaction_at_tokens(ctx.model_id.0.as_ref());
|
|
(fallback, ctx.model_id.clone())
|
|
}
|
|
/// `AuthType` for a subagent: BYOK ⇒ `ApiKey` (don't overwrite the BYOK
|
|
/// key); session-based ACP method ⇒ `SessionToken` (keep refresh wired);
|
|
/// otherwise `ApiKey`.
|
|
fn subagent_auth_type(
|
|
model: Option<&crate::agent::config::ModelEntry>,
|
|
auth_method_id: &acp::AuthMethodId,
|
|
) -> kigi_chat_state::AuthType {
|
|
if model.is_some_and(|m| m.has_own_credentials()) {
|
|
kigi_chat_state::AuthType::ApiKey
|
|
} else if crate::agent::auth_method::is_session_based_method(auth_method_id) {
|
|
kigi_chat_state::AuthType::SessionToken
|
|
} else {
|
|
kigi_chat_state::AuthType::ApiKey
|
|
}
|
|
}
|
|
/// Resolve a model override string (config key or model ID) to a
|
|
/// `(SamplerConfig, ModelId)` pair.
|
|
fn resolve_model_override_to_config(
|
|
model_id: &str,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> Option<(kigi_sampler::SamplerConfig, acp::ModelId)> {
|
|
use crate::agent::config::{resolve_credentials, sampling_config_for_model};
|
|
let entry = crate::agent::config::find_model_by_id(&ctx.available_models, model_id).cloned()?;
|
|
let canonical_model_id = if ctx.available_models.contains_key(model_id) {
|
|
acp::ModelId::new(model_id)
|
|
} else {
|
|
acp::ModelId::new(entry.info().model.clone())
|
|
};
|
|
let session_key = ctx.auth.as_ref().map(|a| a.key.as_str());
|
|
let has_session_key = session_key.is_some();
|
|
let mut credentials = resolve_credentials(&entry, session_key);
|
|
credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id);
|
|
let resolved_auth_type = credentials.auth_type;
|
|
let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone());
|
|
kigi_log::unified_log::debug(
|
|
"subagent resolve_model_override_to_config",
|
|
None,
|
|
Some(serde_json::json!(
|
|
{ "model_id" : model_id, "canonical_model" : canonical_model_id.0
|
|
.as_ref(), "resolved_model_raw" : & config.model, "base_url" : & config
|
|
.base_url, "key_prefix" : key_prefix(& config.api_key),
|
|
"has_own_credentials" : entry.has_own_credentials(), "has_session_key" :
|
|
has_session_key, "auth_type" : format!("{:?}", resolved_auth_type),
|
|
"auth_method_id" : ctx.auth_method_id.0.as_ref(), }
|
|
)),
|
|
);
|
|
Some((config, canonical_model_id))
|
|
}
|
|
/// Leading items to preserve across compaction on resume: the System head only, so the
|
|
/// resumed body (the child's own work) stays compactable. Returns 0 when there's no
|
|
/// leading System; the spawn path then inserts one and bumps the prefix to 1.
|
|
pub(crate) fn resume_inherited_prefix_len(
|
|
conversation: &[kigi_sampling_types::conversation::ConversationItem],
|
|
) -> usize {
|
|
use kigi_sampling_types::conversation::ConversationItem;
|
|
conversation
|
|
.iter()
|
|
.take_while(|i| matches!(i, ConversationItem::System(_)))
|
|
.count()
|
|
}
|
|
/// How a subagent's initial conversation was bootstrapped.
|
|
struct InitialContext {
|
|
source: InitialContextSource,
|
|
copy_error: Option<String>,
|
|
prefix_len: Option<usize>,
|
|
conversation: Vec<kigi_sampling_types::conversation::ConversationItem>,
|
|
/// True only for a verbatim mirror-fork (parent items copied byte-for-byte).
|
|
/// Gates sending the parent tool snapshot so the child's full request prefix
|
|
/// matches the parent. A summarized-fork fallback leaves this false.
|
|
verbatim_fork: bool,
|
|
}
|
|
/// Resume bootstrap: preserve only the System head (see `resume_inherited_prefix_len`).
|
|
fn resume_initial_context(
|
|
conversation: Vec<kigi_sampling_types::conversation::ConversationItem>,
|
|
) -> InitialContext {
|
|
InitialContext {
|
|
source: InitialContextSource::Resumed,
|
|
copy_error: None,
|
|
prefix_len: Some(resume_inherited_prefix_len(&conversation)),
|
|
conversation,
|
|
verbatim_fork: false,
|
|
}
|
|
}
|
|
/// Apply `fork_filter_chat` then normalize; empty or System-only input (no
|
|
/// `<background_context>` produced) fails open to `New`.
|
|
fn forked_initial_context(
|
|
mut items: Vec<kigi_sampling_types::conversation::ConversationItem>,
|
|
) -> InitialContext {
|
|
crate::session::storage::jsonl::fork_filter_chat(&mut items);
|
|
if items.is_empty() {
|
|
return InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some("empty parent conversation".to_string()),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
};
|
|
}
|
|
let (conversation, prefix_len) =
|
|
kigi_subagent_resolution::context::normalize_forked_context(items);
|
|
if prefix_len < 2 {
|
|
return InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some("no inheritable parent content".to_string()),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
};
|
|
}
|
|
InitialContext {
|
|
source: InitialContextSource::Forked,
|
|
copy_error: None,
|
|
prefix_len: Some(prefix_len),
|
|
conversation,
|
|
verbatim_fork: false,
|
|
}
|
|
}
|
|
/// A verbatim mirror requires a coherent tail: the conversation must end on a
|
|
/// plain assistant text response (a clean turn boundary). A dangling assistant
|
|
/// (unanswered tool calls), a trailing ToolResult (mid-turn), or a trailing
|
|
/// user/reasoning means the prefix would be incoherent, so the caller falls back
|
|
/// to the summarized path instead of partial-trimming.
|
|
fn conversation_tail_is_complete(
|
|
items: &[kigi_sampling_types::conversation::ConversationItem],
|
|
) -> bool {
|
|
use kigi_sampling_types::conversation::ConversationItem;
|
|
matches!(
|
|
items.last(), Some(ConversationItem::Assistant(a)) if a.tool_calls.is_empty()
|
|
)
|
|
}
|
|
/// Decide the live-fork context.
|
|
///
|
|
/// Verbatim mirror (the cache-preserving path): when the parent fits the child
|
|
/// window (same 80% guard as resume) AND ends at a clean turn boundary, keep the
|
|
/// items BYTE-FOR-BYTE. We deliberately do NOT run `fork_filter_chat` here — its
|
|
/// step 1 strips synthetic-reason user items (`<system-reminder>`s, drained
|
|
/// monitor events, doom-loop warnings) that the parent actually sent and cached;
|
|
/// stripping them would diverge the child prefix at the first removed item and
|
|
/// cap radix reuse there. At planner spawn the conversation is between turns
|
|
/// (the `/goal` user message is not yet pushed), so the tail is already complete
|
|
/// and no trimming is needed; an incomplete tail falls back to summarized.
|
|
///
|
|
/// Summarized fallback (oversize OR incomplete tail): the reasoning-aware
|
|
/// `fork_filter_chat` drops synthetics + trims the incomplete tail, then
|
|
/// `normalize_forked_context` summarizes. (This is the ONLY path that filters;
|
|
/// the verbatim path never does.)
|
|
///
|
|
/// Input that is empty or only `System` item(s) — before OR after filtering —
|
|
/// inherited nothing, so it fails open to `New` rather than a hollow fork.
|
|
fn verbatim_or_normalize_fork(
|
|
items: Vec<kigi_sampling_types::conversation::ConversationItem>,
|
|
child_context_window: u64,
|
|
) -> InitialContext {
|
|
use kigi_sampling_types::conversation::ConversationItem;
|
|
if !items
|
|
.iter()
|
|
.any(|i| !matches!(i, ConversationItem::System(_)))
|
|
{
|
|
return InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some("forked parent conversation has no inheritable content".to_string()),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
};
|
|
}
|
|
let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&items);
|
|
const SAFE_FORK_PERCENT: u64 = 80;
|
|
let threshold = child_context_window * SAFE_FORK_PERCENT / 100;
|
|
if estimated_tokens <= threshold && conversation_tail_is_complete(&items) {
|
|
let prefix_len = items.len();
|
|
return InitialContext {
|
|
source: InitialContextSource::Forked,
|
|
copy_error: None,
|
|
prefix_len: Some(prefix_len),
|
|
conversation: items,
|
|
verbatim_fork: true,
|
|
};
|
|
}
|
|
let mut filtered = items;
|
|
crate::session::storage::jsonl::fork_filter_chat(&mut filtered);
|
|
if !filtered
|
|
.iter()
|
|
.any(|i| !matches!(i, ConversationItem::System(_)))
|
|
{
|
|
return InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some("no inheritable parent content after filtering".to_string()),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
};
|
|
}
|
|
let (conversation, prefix_len) =
|
|
kigi_subagent_resolution::context::normalize_forked_context(filtered);
|
|
InitialContext {
|
|
source: InitialContextSource::Forked,
|
|
copy_error: None,
|
|
prefix_len: Some(prefix_len),
|
|
conversation,
|
|
verbatim_fork: false,
|
|
}
|
|
}
|
|
/// `true` only when the fork actually summarized (ran `normalize_forked_context`).
|
|
/// A verbatim mirror-fork inherits items as-is and never normalizes, so it reports
|
|
/// `false` even though its source is `Forked`.
|
|
fn fork_context_normalized(source: &InitialContextSource, verbatim_fork: bool) -> bool {
|
|
matches!(source, InitialContextSource::Forked) && !verbatim_fork
|
|
}
|
|
/// Stamp `subagent_fork` / `forked` on the child summary (live path; disk copy already stamps).
|
|
fn stamp_live_fork_session_metadata(
|
|
child_session_info: &SessionInfo,
|
|
parent_session_id: &str,
|
|
parent_prompt_id: Option<String>,
|
|
model_id: &str,
|
|
inherited_prefix_len: Option<usize>,
|
|
fork_context_source: &str,
|
|
) {
|
|
let dir = session::persistence::session_dir(child_session_info);
|
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
|
tracing::warn!(
|
|
error = % e,
|
|
"live fork: could not create child session dir for metadata stamp"
|
|
);
|
|
return;
|
|
}
|
|
let summary_path = dir.join("summary.json");
|
|
let model = acp::ModelId::new(model_id);
|
|
let mut summary = std::fs::read(&summary_path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.or_else(|| session::persistence::Summary::new(child_session_info, model).ok());
|
|
let Some(ref mut summary) = summary else {
|
|
tracing::warn!("live fork: could not load or create child summary");
|
|
return;
|
|
};
|
|
summary.session_kind = Some("subagent_fork".to_string());
|
|
summary.fork_context_source = Some(fork_context_source.to_string());
|
|
summary.parent_session_id = Some(parent_session_id.to_string());
|
|
summary.fork_parent_prompt_id = parent_prompt_id;
|
|
summary.inherited_prefix_len = inherited_prefix_len;
|
|
summary.forked_at = Some(chrono::Utc::now());
|
|
if let Ok(bytes) = serde_json::to_vec_pretty(summary)
|
|
&& let Err(e) = std::fs::write(&summary_path, bytes)
|
|
{
|
|
tracing::warn!(error = % e, "live fork: failed to write forked session summary");
|
|
}
|
|
}
|
|
enum BootstrapInitialContext {
|
|
Ready(InitialContext),
|
|
/// Explicit resume_from failed — abort spawn (fail closed).
|
|
ResumeAbort(String),
|
|
}
|
|
/// Phase 3: resume (fail-closed on copy error) > fork (live then disk, fail-open) > New.
|
|
/// Unresolved non-empty resume is aborted by the caller before this runs.
|
|
async fn bootstrap_initial_context(
|
|
request: &SubagentRequest,
|
|
resume_source: Option<&ResumeSourceData>,
|
|
ctx: &SubagentSpawnContext,
|
|
child_session_info: &SessionInfo,
|
|
child_session_dir: &std::path::Path,
|
|
effective_model_id: &str,
|
|
child_context_window: u64,
|
|
) -> BootstrapInitialContext {
|
|
if request.fork_context && request.resume_from.is_some() {
|
|
tracing::info!(
|
|
subagent_id = % request.id, resume_from = ? request.resume_from,
|
|
resume_resolved = resume_source.is_some(),
|
|
"resume_from and fork_context both set; resolved resume wins (fail-closed on copy error, never forks)"
|
|
);
|
|
}
|
|
if let Some(source) = resume_source {
|
|
let source_session_info = SessionInfo {
|
|
id: acp::SessionId::new(source.child_session_id.clone()),
|
|
cwd: source.child_cwd.clone(),
|
|
};
|
|
let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root(
|
|
crate::util::kigi_home::kigi_home(),
|
|
);
|
|
let copy_options = crate::session::storage::CopySessionOptions {
|
|
parent_session_id: Some(source.child_session_id.clone()),
|
|
new_model_id: Some(effective_model_id.to_string()),
|
|
session_kind: Some("subagent_resume".to_string()),
|
|
fork_context_source: Some("resumed".to_string()),
|
|
fork_parent_prompt_id: request.parent_prompt_id.clone(),
|
|
copy_plan_state: false,
|
|
copy_plan_mode_state: false,
|
|
copy_signals: false,
|
|
copy_tool_state: true,
|
|
fork_filter: false,
|
|
..Default::default()
|
|
};
|
|
return match storage.copy_session_data_sync(
|
|
&source_session_info,
|
|
child_session_info,
|
|
copy_options,
|
|
) {
|
|
Ok(result) => {
|
|
let conversation = match storage.load_chat_history_from_dir(child_session_dir) {
|
|
Ok(items) if !items.is_empty() => items,
|
|
Ok(_) => {
|
|
return BootstrapInitialContext::ResumeAbort(format!(
|
|
"Cannot resume from subagent '{}': \
|
|
copied transcript is empty",
|
|
source.subagent_id,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
return BootstrapInitialContext::ResumeAbort(format!(
|
|
"Cannot resume from subagent '{}': \
|
|
failed to load copied transcript: {e}",
|
|
source.subagent_id,
|
|
));
|
|
}
|
|
};
|
|
let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&conversation);
|
|
const SAFE_RESUME_PERCENT: u64 = 80;
|
|
let threshold = child_context_window * SAFE_RESUME_PERCENT / 100;
|
|
if estimated_tokens > threshold {
|
|
return BootstrapInitialContext::ResumeAbort(format!(
|
|
"Cannot resume from subagent '{}': source transcript \
|
|
(~{estimated_tokens} tokens) exceeds {SAFE_RESUME_PERCENT}% of \
|
|
the model's context window ({child_context_window} tokens). \
|
|
The source conversation is too large for the current model.",
|
|
source.subagent_id,
|
|
));
|
|
}
|
|
tracing::info!(
|
|
subagent_id = % request.id, source_subagent = % source.subagent_id,
|
|
chat_messages = result.chat_messages_copied, tool_state = result
|
|
.tool_state_copied, estimated_tokens,
|
|
"Resume-copied source child session data into new child"
|
|
);
|
|
BootstrapInitialContext::Ready(resume_initial_context(conversation))
|
|
}
|
|
Err(e) => BootstrapInitialContext::ResumeAbort(format!(
|
|
"Cannot resume from subagent '{}': failed to copy source session data: {e}",
|
|
source.subagent_id,
|
|
)),
|
|
};
|
|
}
|
|
if !request.fork_context {
|
|
return BootstrapInitialContext::Ready(InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: None,
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
});
|
|
}
|
|
let live_items = match ctx.parent_chat_state.as_ref() {
|
|
Some(chat_state) => {
|
|
let items = chat_state.get_conversation().await;
|
|
if items.is_empty() { None } else { Some(items) }
|
|
}
|
|
None => None,
|
|
};
|
|
if let Some(items) = live_items {
|
|
let ctx_out = verbatim_or_normalize_fork(items, child_context_window);
|
|
tracing::info!(
|
|
subagent_id = % request.id, subagent_type = % request.subagent_type,
|
|
loaded_items = ctx_out.conversation.len(), source = ? ctx_out.source,
|
|
verbatim = ctx_out.verbatim_fork,
|
|
"Forked context from live parent_chat_state"
|
|
);
|
|
if matches!(ctx_out.source, InitialContextSource::Forked) {
|
|
let marker = if ctx_out.verbatim_fork {
|
|
"forked_verbatim"
|
|
} else {
|
|
"forked_summarized"
|
|
};
|
|
stamp_live_fork_session_metadata(
|
|
child_session_info,
|
|
&ctx.parent_session_id,
|
|
request.parent_prompt_id.clone(),
|
|
effective_model_id,
|
|
ctx_out.prefix_len,
|
|
marker,
|
|
);
|
|
}
|
|
return BootstrapInitialContext::Ready(ctx_out);
|
|
}
|
|
if let Some(ref parent_info) = ctx.parent_session_info {
|
|
let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root(
|
|
crate::util::kigi_home::kigi_home(),
|
|
);
|
|
let copy_options = crate::session::storage::CopySessionOptions {
|
|
parent_session_id: Some(ctx.parent_session_id.clone()),
|
|
new_model_id: Some(effective_model_id.to_string()),
|
|
session_kind: Some("subagent_fork".to_string()),
|
|
fork_context_source: Some("forked".to_string()),
|
|
fork_parent_prompt_id: request.parent_prompt_id.clone(),
|
|
copy_plan_state: false,
|
|
copy_plan_mode_state: false,
|
|
copy_signals: false,
|
|
copy_tool_state: false,
|
|
fork_filter: true,
|
|
..Default::default()
|
|
};
|
|
return match storage.copy_session_data_sync(parent_info, child_session_info, copy_options) {
|
|
Ok(result) => {
|
|
tracing::info!(
|
|
subagent_id = % request.id, subagent_type = % request.subagent_type,
|
|
chat_messages = result.chat_messages_copied, tool_state = result
|
|
.tool_state_copied,
|
|
"Fork-copied parent session data into child (disk fallback)"
|
|
);
|
|
let items = storage
|
|
.load_chat_history_from_dir(child_session_dir)
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!(
|
|
error = % e,
|
|
"Failed to load forked chat history, starting with empty context"
|
|
);
|
|
vec![]
|
|
});
|
|
BootstrapInitialContext::Ready(forked_initial_context(items))
|
|
}
|
|
Err(e) => {
|
|
let err_msg = format!("{e}");
|
|
tracing::warn!(
|
|
subagent_id = % request.id, subagent_type = % request.subagent_type,
|
|
error = % e,
|
|
"Failed to fork-copy parent session, falling back to fresh"
|
|
);
|
|
BootstrapInitialContext::Ready(InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some(err_msg),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
})
|
|
}
|
|
};
|
|
}
|
|
tracing::warn!(
|
|
subagent_id = % request.id, subagent_type = % request.subagent_type,
|
|
"fork_context=true but no live parent conversation or parent_session_info; falling back to fresh"
|
|
);
|
|
BootstrapInitialContext::Ready(InitialContext {
|
|
source: InitialContextSource::New,
|
|
copy_error: Some("parent conversation unavailable".to_string()),
|
|
prefix_len: None,
|
|
conversation: vec![],
|
|
verbatim_fork: false,
|
|
})
|
|
}
|
|
/// Drop guard that moves a pending coordinator entry to completed-as-failed
|
|
/// on early return. Call `defuse()` after promoting to active.
|
|
struct PendingGuard<'a> {
|
|
coordinator: &'a std::cell::RefCell<SubagentCoordinator>,
|
|
id: String,
|
|
defused: bool,
|
|
/// Specific error message set by fail_subagent before returning.
|
|
/// Falls back to a generic message if unset.
|
|
error: Option<String>,
|
|
}
|
|
impl PendingGuard<'_> {
|
|
fn defuse(mut self) {
|
|
self.defused = true;
|
|
}
|
|
fn set_error(&mut self, error: String) {
|
|
self.error = Some(error);
|
|
}
|
|
}
|
|
impl Drop for PendingGuard<'_> {
|
|
fn drop(&mut self) {
|
|
if !self.defused {
|
|
let error = self
|
|
.error
|
|
.take()
|
|
.unwrap_or_else(|| "Subagent failed during initialization".to_string());
|
|
self.coordinator
|
|
.borrow_mut()
|
|
.move_pending_to_failed(&self.id, &error);
|
|
}
|
|
}
|
|
}
|
|
/// Resolve the effective working directory for a child session.
|
|
///
|
|
/// Precedence: worktree path > `override_cwd` (non-empty) > parent cwd. The
|
|
/// caller selects `override_cwd`: a resumed child inherits the source's
|
|
/// effective cwd, a fresh spawn honors its `request.cwd`.
|
|
fn resolve_child_cwd(
|
|
worktree_path: Option<&Path>,
|
|
override_cwd: Option<&str>,
|
|
parent_cwd: &Path,
|
|
) -> PathBuf {
|
|
worktree_path
|
|
.map(Path::to_path_buf)
|
|
.or_else(|| override_cwd.filter(|s| !s.is_empty()).map(PathBuf::from))
|
|
.unwrap_or_else(|| parent_cwd.to_path_buf())
|
|
}
|
|
/// The cwd a resumed child inherits from its source subagent, or `None` when
|
|
/// there is nothing to inherit (the caller then falls back to the parent cwd).
|
|
///
|
|
/// Only non-worktree sources inherit here — worktree-backed sources are reused
|
|
/// by the worktree path. The cwd is existence-checked because a source can be
|
|
/// pinned into a sibling's worktree that the snapshot stack later disposes;
|
|
/// resume otherwise skips cwd validation.
|
|
fn resume_inherited_cwd(source: Option<&ResumeSourceData>) -> Option<&str> {
|
|
let source = source?;
|
|
if source.worktree_path.is_some() || source.child_cwd.is_empty() {
|
|
return None;
|
|
}
|
|
if !Path::new(&source.child_cwd).is_dir() {
|
|
tracing::warn!(
|
|
source_subagent_id = % source.subagent_id, child_cwd = % source.child_cwd,
|
|
"Resume source cwd no longer exists; using parent workspace"
|
|
);
|
|
return None;
|
|
}
|
|
Some(source.child_cwd.as_str())
|
|
}
|
|
/// Select the cwd override for a child: a resume inherits the source's cwd
|
|
/// (never its own `request.cwd`); a fresh spawn uses `request.cwd`.
|
|
fn select_override_cwd<'a>(
|
|
resume_source: Option<&'a ResumeSourceData>,
|
|
request_cwd: Option<&'a str>,
|
|
) -> Option<&'a str> {
|
|
if resume_source.is_some() {
|
|
resume_inherited_cwd(resume_source)
|
|
} else {
|
|
request_cwd
|
|
}
|
|
}
|
|
/// Apply `McpInheritance` filtering to a parent MCP pool snapshot.
|
|
///
|
|
/// Returns `None` for `McpInheritance::None` (no pool at all — avoids
|
|
/// an empty import call downstream). For `Named`/`Except`, retains or
|
|
/// removes the matching server names in-place.
|
|
fn filter_pool_by_inheritance(
|
|
mut pool: crate::session::mcp_servers::SharedMcpPool,
|
|
inheritance: &kigi_agent::config::McpInheritance,
|
|
) -> Option<crate::session::mcp_servers::SharedMcpPool> {
|
|
use kigi_agent::config::McpInheritance;
|
|
match inheritance {
|
|
McpInheritance::All => Some(pool),
|
|
McpInheritance::None => None,
|
|
McpInheritance::Named(names) => {
|
|
let before = pool.server_names().count();
|
|
pool.retain_clients(|name| names.iter().any(|n| n == name));
|
|
tracing::debug!(
|
|
before,
|
|
after = pool.server_names().count(),
|
|
?names,
|
|
"MCP inheritance: Named filter applied"
|
|
);
|
|
Some(pool)
|
|
}
|
|
McpInheritance::Except(names) => {
|
|
let before = pool.server_names().count();
|
|
pool.retain_clients(|name| !names.iter().any(|n| n == name));
|
|
tracing::debug!(
|
|
before,
|
|
after = pool.server_names().count(),
|
|
?names,
|
|
"MCP inheritance: Except filter applied"
|
|
);
|
|
Some(pool)
|
|
}
|
|
}
|
|
}
|
|
/// Resolve a subagent type name to its `AgentDefinition`, with the parent
|
|
/// session's CLI tool/permission overrides already applied (so the spawn path
|
|
/// can never obtain a definition that skips them).
|
|
fn resolve_agent_definition(
|
|
subagent_type: &str,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> Option<kigi_agent::config::AgentDefinition> {
|
|
let mut def = kigi_agent::discovery::by_name_in_cwd_with_plugins(
|
|
subagent_type,
|
|
&ctx.parent_cwd,
|
|
ctx.plugin_registry.as_deref(),
|
|
)
|
|
.or_else(|| {
|
|
ctx.agent_config.as_ref().and_then(|cfg| {
|
|
cfg.cli_agents
|
|
.iter()
|
|
.find(|d| d.name == subagent_type)
|
|
.cloned()
|
|
})
|
|
})?;
|
|
ctx.apply_session_cli_overrides(&mut def);
|
|
Some(def)
|
|
}
|
|
/// Minimal per-session context for `validate_subagent_type`.
|
|
/// Avoids the heavy `SubagentSpawnContext` clone on the validation hot path.
|
|
#[derive(Default)]
|
|
pub(crate) struct SubagentValidationContext {
|
|
pub parent_cwd: PathBuf,
|
|
pub plugin_registry: Option<Arc<kigi_agent::plugins::PluginRegistry>>,
|
|
pub subagent_toggle: HashMap<String, bool>,
|
|
pub allowed_subagent_types: Option<Vec<String>>,
|
|
pub cli_agent_names: Vec<String>,
|
|
}
|
|
impl SubagentValidationContext {
|
|
/// Toggle lookup; absent keys default to enabled.
|
|
pub(crate) fn is_subagent_enabled(&self, name: &str) -> bool {
|
|
self.subagent_toggle.get(name).copied().unwrap_or(true)
|
|
}
|
|
}
|
|
/// Synchronously validate a subagent type against discovery + toggle + allow-list.
|
|
/// `Unknown { available }` is sorted by `str::cmp` for stable rendering.
|
|
pub(crate) fn validate_subagent_type(
|
|
subagent_type: &str,
|
|
ctx: &SubagentValidationContext,
|
|
) -> SubagentValidateTypeOutcome {
|
|
let resolves = ctx.cli_agent_names.iter().any(|n| n == subagent_type)
|
|
|| kigi_agent::discovery::by_name_in_cwd_with_plugins(
|
|
subagent_type,
|
|
&ctx.parent_cwd,
|
|
ctx.plugin_registry.as_deref(),
|
|
)
|
|
.is_some();
|
|
if !resolves {
|
|
let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
|
|
&ctx.parent_cwd,
|
|
&ctx.subagent_toggle,
|
|
ctx.plugin_registry.as_deref(),
|
|
)
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.collect();
|
|
let mut seen: std::collections::HashSet<String> = available.iter().cloned().collect();
|
|
for name in &ctx.cli_agent_names {
|
|
if !ctx.is_subagent_enabled(name) {
|
|
continue;
|
|
}
|
|
if seen.insert(name.clone()) {
|
|
available.push(name.clone());
|
|
}
|
|
}
|
|
available.sort();
|
|
return SubagentValidateTypeOutcome::Unknown { available };
|
|
}
|
|
if !ctx.is_subagent_enabled(subagent_type) {
|
|
return SubagentValidateTypeOutcome::Disabled;
|
|
}
|
|
if let Some(ref allowed) = ctx.allowed_subagent_types
|
|
&& !allowed
|
|
.iter()
|
|
.any(|t| t.eq_ignore_ascii_case(subagent_type))
|
|
{
|
|
return SubagentValidateTypeOutcome::NotAllowed {
|
|
allowed: allowed.clone(),
|
|
};
|
|
}
|
|
SubagentValidateTypeOutcome::Ok
|
|
}
|
|
/// Gate an already-resolved subagent type against the `[subagents.toggle]`
|
|
/// disable map and the parent's allow-list.
|
|
///
|
|
/// The caller must have already confirmed the type resolves to an
|
|
/// `AgentDefinition`; this checks ONLY the toggle + allow-list gates,
|
|
/// returning `Ok` when the type may run and `Disabled` / `NotAllowed`
|
|
/// otherwise (never `Unknown` / `ValidationUnavailable`). Shared by
|
|
/// [`handle_subagent_request`] and [`describe_subagent_type`] so both apply
|
|
/// identical gates.
|
|
fn gate_subagent_type(
|
|
subagent_type: &str,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> SubagentValidateTypeOutcome {
|
|
if !ctx.is_subagent_enabled(subagent_type) {
|
|
return SubagentValidateTypeOutcome::Disabled;
|
|
}
|
|
if let Some(ref allowed) = ctx.allowed_subagent_types
|
|
&& !allowed
|
|
.iter()
|
|
.any(|t| t.eq_ignore_ascii_case(subagent_type))
|
|
{
|
|
return SubagentValidateTypeOutcome::NotAllowed {
|
|
allowed: allowed.clone(),
|
|
};
|
|
}
|
|
SubagentValidateTypeOutcome::Ok
|
|
}
|
|
/// `false` twin: the alternate flavors re-select toolset presets and
|
|
/// templates, so none is representable when the optional
|
|
/// harness is compiled out. Keeps ungated call sites compiling.
|
|
pub(crate) fn subagent_harness_flavor_is_representable(_agent_type: &str) -> bool {
|
|
false
|
|
}
|
|
/// Apply the harness-dependent toolset/prompt re-selection to a resolved
|
|
/// agent definition.
|
|
///
|
|
/// The harness flavor (alternate vs kigi) normally follows the PARENT
|
|
/// agent: `KigiOrchestrator` parents give children
|
|
/// the alternate harness; the orchestrator keeps children lean, and other parents
|
|
/// inherit the file-tool override (hashline vs standard). A `/goal` role may
|
|
/// pass `harness_agent_type` to OVERRIDE that flavor regardless of the parent
|
|
/// (so a kigi session can run an alternate-harness verifier and vice-versa);
|
|
/// `None` for every non-goal spawn ⇒ the parent decides (unchanged). The base
|
|
/// toolset stays role-dependent on `subagent_type` (general-purpose →
|
|
/// implementer, else explorer), so the role keeps a capable toolset on the
|
|
/// chosen harness.
|
|
///
|
|
/// Extracted so both [`handle_subagent_request`] (real spawn) and
|
|
/// [`describe_subagent_type`] (read-only probe) build the SAME `tool_config`
|
|
/// for a given `(subagent_type, harness_agent_type, parent_name)` — no
|
|
/// duplication.
|
|
fn resolve_subagent_toolset(
|
|
#[allow(unused_variables)] subagent_type: &str,
|
|
harness_agent_type: Option<&str>,
|
|
ctx: &SubagentSpawnContext,
|
|
definition: &mut kigi_agent::config::AgentDefinition,
|
|
) {
|
|
let flavor_agent = match harness_agent_type {
|
|
Some(h) => Some(h),
|
|
None => ctx
|
|
.parent_agent_name
|
|
.as_deref()
|
|
.filter(|s| subagent_harness_flavor_is_representable(s))
|
|
.or(ctx.parent_model_agent_type.as_deref()),
|
|
};
|
|
if flavor_agent.is_some_and(subagent_harness_flavor_is_representable) {
|
|
} else if let Some(ref file_tools) = ctx.file_tool_overrides {
|
|
definition.override_file_tools(file_tools.clone());
|
|
}
|
|
}
|
|
/// Map a resolved `ToolServerConfig` into a [`SubagentTypeSummary`].
|
|
///
|
|
/// Keys on each entry's `ToolConfig.kind` (first tool per kind wins).
|
|
/// Entries with `kind: None` — `from_id`/MCP/custom tools — are SKIPPED, so
|
|
/// this is NOT a byte-for-byte equivalent of the finalize-time `kind_to_name`
|
|
/// map (which keys on the registry `entry.kind`); the two agree for the
|
|
/// builtin goal toolsets, where every tool's kind is populated by
|
|
/// `From<&T: Tool>`, but diverge for `kind: None` tools (which carry no
|
|
/// capability signal anyway). The client-facing name is
|
|
/// `ToolConfig::resolve_client_name(default_id)` where `default_id` is the
|
|
/// unqualified tool id (the `"<namespace>:"` prefix on `tc.id` is stripped),
|
|
/// so a `name_override` is reflected. The read/search/execute flags are what
|
|
/// the per-role capability gates key on.
|
|
fn summarize_tool_config(
|
|
config: &kigi_tools::registry::types::ToolServerConfig,
|
|
) -> SubagentTypeSummary {
|
|
use kigi_tools::types::tool::ToolKind;
|
|
use std::collections::HashMap;
|
|
let mut tool_names: HashMap<ToolKind, String> = HashMap::new();
|
|
for tc in &config.tools {
|
|
let Some(kind) = tc.kind else { continue };
|
|
let default_id = tc.id.rsplit(':').next().unwrap_or(tc.id.as_str());
|
|
let client_name = tc.resolve_client_name(default_id);
|
|
tool_names.entry(kind).or_insert(client_name);
|
|
}
|
|
SubagentTypeSummary {
|
|
can_read: tool_names.contains_key(&ToolKind::Read),
|
|
can_search: tool_names.contains_key(&ToolKind::Search),
|
|
can_execute: tool_names.contains_key(&ToolKind::Execute),
|
|
tool_names,
|
|
}
|
|
}
|
|
/// Describe a subagent type's resolved toolset WITHOUT spawning it.
|
|
///
|
|
/// Runs the same resolution path as [`handle_subagent_request`] —
|
|
/// [`resolve_agent_definition`] + [`gate_subagent_type`] +
|
|
/// [`resolve_subagent_toolset`] — then summarizes the resulting
|
|
/// `tool_config`. Backs the `SubagentEvent::DescribeType` drain arm; the
|
|
/// parent uses the summary for the per-role capability gate and prompt
|
|
/// rendering before committing a configured `/goal` `{model, agent_type}` pair.
|
|
///
|
|
/// `harness_agent_type` is the `/goal`-only harness override: when set it must
|
|
/// resolve to an `AgentDefinition` via this module's [`resolve_agent_definition`]
|
|
/// (name-based project/plugin/builtin lookup — `by_name_in_cwd_with_plugins` +
|
|
/// `BuiltinAgentName`). That is equivalent to the main session for builtin
|
|
/// harness names but does NOT apply the main session's env / ACP-profile /
|
|
/// strict-harness precedence. An unresolvable harness returns `Unknown` so the
|
|
/// `/goal` caller fails open to the session harness; otherwise it decides the
|
|
/// summarized toolset's flavor. `None` (every non-goal probe) defers the flavor
|
|
/// to the parent agent (unchanged).
|
|
pub(crate) fn describe_subagent_type(
|
|
subagent_type: &str,
|
|
harness_agent_type: Option<&str>,
|
|
ctx: &SubagentSpawnContext,
|
|
) -> SubagentDescribeOutcome {
|
|
if let Some(harness) = harness_agent_type
|
|
&& resolve_agent_definition(harness, ctx).is_none()
|
|
{
|
|
let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
|
|
&ctx.parent_cwd,
|
|
&ctx.subagent_toggle,
|
|
ctx.plugin_registry.as_deref(),
|
|
)
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.collect();
|
|
available.sort();
|
|
return SubagentDescribeOutcome::Unknown { available };
|
|
}
|
|
let Some(mut definition) = resolve_agent_definition(subagent_type, ctx) else {
|
|
let mut available: Vec<String> = kigi_agent::discovery::all_subagents_with_plugins(
|
|
&ctx.parent_cwd,
|
|
&ctx.subagent_toggle,
|
|
ctx.plugin_registry.as_deref(),
|
|
)
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.collect();
|
|
available.sort();
|
|
return SubagentDescribeOutcome::Unknown { available };
|
|
};
|
|
match gate_subagent_type(subagent_type, ctx) {
|
|
SubagentValidateTypeOutcome::Disabled => return SubagentDescribeOutcome::Disabled,
|
|
SubagentValidateTypeOutcome::NotAllowed { allowed } => {
|
|
return SubagentDescribeOutcome::NotAllowed { allowed };
|
|
}
|
|
_ => {}
|
|
}
|
|
resolve_subagent_toolset(subagent_type, harness_agent_type, ctx, &mut definition);
|
|
SubagentDescribeOutcome::Ok(summarize_tool_config(&definition.tool_config))
|
|
}
|
|
/// Resolve a subagent's turn limit: its own `maxTurns` wins, else inherit the parent's.
|
|
fn resolve_subagent_max_turns(
|
|
definition_max_turns: Option<u32>,
|
|
parent_max_turns: Option<usize>,
|
|
) -> Option<usize> {
|
|
definition_max_turns
|
|
.map(|v| v as usize)
|
|
.or(parent_max_turns)
|
|
}
|
|
/// What to do with a resumed subagent's isolated worktree directory.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ResumeWorktreeAction {
|
|
/// Directory on disk and no snapshot ref — reuse it as-is.
|
|
Reuse,
|
|
/// Directory gone but a snapshot ref exists — rehydrate from it.
|
|
Rehydrate,
|
|
/// Directory gone and no snapshot — fall back to the shared workspace.
|
|
Shared,
|
|
}
|
|
/// Decide how to recover a resumed subagent's worktree from its on-disk state
|
|
/// and whether a durable snapshot is available. Pure so the three outcomes are
|
|
/// unit-testable without git/async.
|
|
fn resume_worktree_action(dir_exists: bool, snapshot_ref: Option<&str>) -> ResumeWorktreeAction {
|
|
if snapshot_ref.is_some() {
|
|
ResumeWorktreeAction::Rehydrate
|
|
} else if dir_exists {
|
|
ResumeWorktreeAction::Reuse
|
|
} else {
|
|
ResumeWorktreeAction::Shared
|
|
}
|
|
}
|
|
/// The parent session's working directory — the source path for a subagent
|
|
/// worktree. Prefers the reconstructed `SessionInfo` cwd, falling back to
|
|
/// `parent_cwd`.
|
|
fn parent_source_cwd(ctx: &SubagentSpawnContext) -> std::path::PathBuf {
|
|
ctx.parent_session_info
|
|
.as_ref()
|
|
.map(|i| std::path::PathBuf::from(&i.cwd))
|
|
.unwrap_or_else(|| std::path::PathBuf::from(&ctx.parent_cwd))
|
|
}
|
|
/// Effective permission mode for a spawned subagent. Plugin agents never honor a
|
|
/// non-default mode; under the pin, `bypassPermissions` downgrades to `Default`
|
|
/// so a repo/profile/`--agents` def can't restore auto-approve. Caller logs it.
|
|
fn resolve_subagent_permission_mode(
|
|
requested: kigi_agent::config::PermissionMode,
|
|
is_plugin: bool,
|
|
policy_block: Option<&'static str>,
|
|
) -> kigi_agent::config::PermissionMode {
|
|
use kigi_agent::config::PermissionMode;
|
|
if is_plugin {
|
|
return PermissionMode::Default;
|
|
}
|
|
if policy_block.is_some() && requested == PermissionMode::BypassPermissions {
|
|
return PermissionMode::Default;
|
|
}
|
|
requested
|
|
}
|
|
/// Main repo root for a subagent's source: the durable repo a completion snapshot is transferred into and the repo a resume rehydrates from — both arms MUST resolve this identically.
|
|
fn resolve_subagent_source_repo(ctx: &SubagentSpawnContext) -> std::path::PathBuf {
|
|
let source_cwd = parent_source_cwd(ctx);
|
|
kigi_workspace::session::git::find_main_repo_root_from_path(&source_cwd).unwrap_or(source_cwd)
|
|
}
|
|
enum SubagentWaitOutcome {
|
|
Cancelled,
|
|
TurnResult(Box<Result<SubagentPromptTurnResult, oneshot::error::RecvError>>),
|
|
}
|
|
async fn await_subagent_turn_or_cancellation(
|
|
prompt_rx: oneshot::Receiver<SubagentPromptTurnResult>,
|
|
cancel_token: CancellationToken,
|
|
) -> SubagentWaitOutcome {
|
|
tokio::select! {
|
|
_ = cancel_token.cancelled() => SubagentWaitOutcome::Cancelled, turn_result =
|
|
prompt_rx => SubagentWaitOutcome::TurnResult(Box::new(turn_result)),
|
|
}
|
|
}
|
|
/// Max time a blocking `spawn_subagent` may hold the turn before it is
|
|
/// auto-backgrounded (non-destructively). Env override: `KIGI_SUBAGENT_AWAIT_BUDGET_MS`.
|
|
const SUBAGENT_AWAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(600);
|
|
fn subagent_await_budget() -> std::time::Duration {
|
|
std::env::var("KIGI_SUBAGENT_AWAIT_BUDGET_MS")
|
|
.ok()
|
|
.and_then(|v| v.parse::<u64>().ok())
|
|
.filter(|&ms| ms > 0)
|
|
.map(std::time::Duration::from_millis)
|
|
.unwrap_or(SUBAGENT_AWAIT_BUDGET)
|
|
}
|
|
/// Fallback for cancelled/errored paths where TurnDeltaSnapshot is unavailable.
|
|
async fn signals_snapshot_counts(child_handle: &SessionHandle) -> (u32, u32) {
|
|
child_handle
|
|
.signals_handle
|
|
.snapshot()
|
|
.await
|
|
.map(|s| (s.tool_call_count, s.turn_count))
|
|
.unwrap_or((0, 0))
|
|
}
|
|
fn cancellation_error_message(
|
|
category: Option<kigi_file_utils::events::types::CancellationCategory>,
|
|
context: Option<&crate::session::commands::CancellationContext>,
|
|
) -> String {
|
|
use kigi_file_utils::events::types::CancellationCategory;
|
|
let detail = context.and_then(|ctx| {
|
|
let tool = ctx.tool_name.as_deref();
|
|
let reason = ctx.reason.as_deref();
|
|
let hook = ctx.hook_name.as_deref();
|
|
match (tool, reason, hook) {
|
|
(Some(t), Some(r), Some(h)) => Some(format!("{r} for tool `{t}` (hook: {h})")),
|
|
(Some(t), Some(r), None) => Some(format!("{r} for tool `{t}`")),
|
|
(Some(t), None, _) => Some(format!("tool `{t}`")),
|
|
_ => None,
|
|
}
|
|
});
|
|
match (category, &detail) {
|
|
(Some(CancellationCategory::PermissionRejected), Some(d)) => {
|
|
format!("Subagent turn was cancelled: user rejected permission — {d}")
|
|
}
|
|
(Some(CancellationCategory::PermissionRejected), None) => {
|
|
"Subagent turn was cancelled: user rejected a permission prompt".to_string()
|
|
}
|
|
(Some(CancellationCategory::PermissionCancelled), _) => {
|
|
"Subagent turn was cancelled: user cancelled a permission prompt".to_string()
|
|
}
|
|
(Some(CancellationCategory::HookDenied), Some(d)) => {
|
|
format!("Subagent turn was cancelled: hook denied — {d}")
|
|
}
|
|
(Some(CancellationCategory::HookDenied), None) => {
|
|
"Subagent turn was cancelled: blocked by a hook".to_string()
|
|
}
|
|
(Some(CancellationCategory::MidTurnAbort), _) => {
|
|
"Subagent turn was cancelled: aborted mid-turn".to_string()
|
|
}
|
|
_ => "Subagent turn was cancelled".to_string(),
|
|
}
|
|
}
|
|
/// Whether a completed subagent should trigger an auto-wake synthetic prompt.
|
|
///
|
|
/// Returns `true` only for background subagents with auto-wake enabled whose
|
|
/// result has not already been consumed (via block-wait or explicit kill).
|
|
/// Also suppressed while the parent's goal loop is active (mirrors the bash
|
|
/// gate in `notification_bridge`); skipping the inject also skips the
|
|
/// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it.
|
|
/// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own
|
|
/// no-channel bail into the decision, so the `will_wake` stamped on the
|
|
/// completion notification can never promise a wake the inject won't do.
|
|
///
|
|
/// `cancelled` results never wake: a child dies cancelled because the user
|
|
/// (or parent teardown) killed it — most acutely the Ctrl+C race where
|
|
/// `ParentGone` detaches a foreground child to background moments before the
|
|
/// in-flight `SubagentEvent::Cancel` lands its token, which would otherwise
|
|
/// wake the model right after the user stopped everything. The completion is
|
|
/// still recorded, so reminder/drain surfaces can report it later.
|
|
fn should_auto_wake_subagent(
|
|
run_in_background: bool,
|
|
cancelled: bool,
|
|
auto_wake_enabled: bool,
|
|
block_waited: bool,
|
|
explicitly_killed: bool,
|
|
goal_loop_active: bool,
|
|
parent_channel_open: bool,
|
|
) -> bool {
|
|
run_in_background
|
|
&& !cancelled
|
|
&& auto_wake_enabled
|
|
&& !block_waited
|
|
&& !explicitly_killed
|
|
&& !goal_loop_active
|
|
&& parent_channel_open
|
|
}
|
|
/// Inject a synthetic prompt into the parent session for a completed background
|
|
/// subagent, enabling auto-wake when the agent is idle.
|
|
///
|
|
/// Only called for background subagents when auto-wake is enabled
|
|
/// and the result has not been consumed (via block-wait or explicit kill).
|
|
fn inject_subagent_completed_prompt(
|
|
subagent_id: &str,
|
|
result: &SubagentResult,
|
|
request: &SubagentRequest,
|
|
auto_wake_delivered: &Option<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
task_output_tool_name: &str,
|
|
) {
|
|
let Some(cmd_tx) = parent_cmd_tx else {
|
|
return;
|
|
};
|
|
if let Some(auto_wake) = auto_wake_delivered {
|
|
auto_wake.insert(subagent_id.to_string());
|
|
}
|
|
let summary = SubagentCompletionSummary {
|
|
subagent_id: subagent_id.to_string(),
|
|
subagent_type: request.subagent_type.clone(),
|
|
description: request.description.clone(),
|
|
success: result.success && !result.cancelled,
|
|
duration_ms: result.duration_ms,
|
|
tool_calls: result.tool_calls,
|
|
turns: result.turns,
|
|
output: result.output.clone(),
|
|
};
|
|
let message = kigi_tools::reminders::task_completion::format_subagent_completion(
|
|
&summary,
|
|
Some(task_output_tool_name),
|
|
);
|
|
let wrapped = kigi_tools::reminders::wrap_reminder(&message);
|
|
let prompt_id = format!("subagent-completed-{subagent_id}");
|
|
let (respond_to, _completion_rx) = tokio::sync::oneshot::channel();
|
|
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))];
|
|
let _ = cmd_tx.send(SessionCommand::Prompt {
|
|
prompt_id,
|
|
prompt_blocks,
|
|
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
|
|
client_identifier: None,
|
|
screen_mode: None,
|
|
verbatim: true,
|
|
traceparent: None,
|
|
json_schema: None,
|
|
send_now: false,
|
|
respond_to,
|
|
persist_ack: None,
|
|
});
|
|
}
|
|
/// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot;
|
|
/// `PendingGuard::drop` handles the queue side effects.
|
|
fn send_failure(request: SubagentRequest, error: &str) {
|
|
let _ = request.result_tx.send(SubagentResult {
|
|
success: false,
|
|
error: Some(error.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
/// Fail BEFORE `insert_pending`. Sends via oneshot; for background-mode
|
|
/// requests also records a synthetic `CompletedSubagent` + emits a
|
|
/// `SubagentFinished` notification (persisted + live).
|
|
fn send_pre_spawn_failure(
|
|
request: SubagentRequest,
|
|
error: &str,
|
|
coordinator: &std::cell::RefCell<SubagentCoordinator>,
|
|
ctx: &SubagentSpawnContext,
|
|
gateway: &GatewaySender,
|
|
) {
|
|
let SubagentRequest {
|
|
id,
|
|
subagent_type,
|
|
description,
|
|
parent_prompt_id,
|
|
result_tx,
|
|
run_in_background,
|
|
surface_completion,
|
|
..
|
|
} = request;
|
|
if run_in_background {
|
|
let notification_subagent_id = id.clone();
|
|
coordinator.borrow_mut().record_pre_spawn_failure(
|
|
id,
|
|
subagent_type,
|
|
description,
|
|
parent_prompt_id,
|
|
ctx.parent_session_id.clone(),
|
|
error,
|
|
surface_completion,
|
|
);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id: notification_subagent_id,
|
|
child_session_id: String::new(),
|
|
status: "failed".to_string(),
|
|
error: Some(error.to_string()),
|
|
tool_calls: 0,
|
|
turns: 0,
|
|
duration_ms: 0,
|
|
tokens_used: 0,
|
|
output: None,
|
|
will_wake: false,
|
|
},
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
);
|
|
}
|
|
let _ = result_tx.send(SubagentResult {
|
|
success: false,
|
|
error: Some(error.to_string()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
/// Post-`SubagentSpawned` failure: oneshot + `SubagentFinished` + `meta.json` update.
|
|
fn fail_subagent(
|
|
request: SubagentRequest,
|
|
error: &str,
|
|
subagent_id: &str,
|
|
child_session_id: &acp::SessionId,
|
|
subagent_meta_dir: &Path,
|
|
gateway: &GatewaySender,
|
|
parent_session_id: &str,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
duration_ms: u64,
|
|
) {
|
|
let result = SubagentResult {
|
|
success: false,
|
|
error: Some(error.to_string()),
|
|
subagent_id: subagent_id.to_string(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
duration_ms,
|
|
..Default::default()
|
|
};
|
|
update_subagent_meta_completed(subagent_meta_dir, &result);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
parent_session_id,
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id: subagent_id.to_string(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
status: result.status().to_string(),
|
|
error: result.error.clone(),
|
|
tool_calls: 0,
|
|
turns: 0,
|
|
duration_ms,
|
|
tokens_used: 0,
|
|
output: None,
|
|
will_wake: false,
|
|
},
|
|
parent_cmd_tx,
|
|
);
|
|
let _ = request.result_tx.send(result);
|
|
}
|
|
/// Tear down a subagent killed while pending: shut the idle child, dispose its
|
|
/// worktree (only if `worktree_freshly_created` — a resumed subagent's aliases
|
|
/// the source's and must survive), persist + emit a single cancelled
|
|
/// `SubagentFinished`, move the entry to completed-as-cancelled (stays
|
|
/// queryable), and deliver the result. Defuse the `PendingGuard` before calling.
|
|
async fn cancel_pending_subagent_at_promote(
|
|
request: SubagentRequest,
|
|
child_handle: &SessionHandle,
|
|
subagent_id: &str,
|
|
child_session_id: &acp::SessionId,
|
|
subagent_meta_dir: &Path,
|
|
coordinator: &std::cell::RefCell<SubagentCoordinator>,
|
|
gateway: &GatewaySender,
|
|
parent_session_id: &str,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
worktree_path: Option<&Path>,
|
|
worktree_freshly_created: bool,
|
|
duration_ms: u64,
|
|
) {
|
|
let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown);
|
|
if worktree_freshly_created
|
|
&& let Some(wt_path) = worktree_path
|
|
&& let Err(e) = crate::session::worktree::remove_subagent_worktree(wt_path).await
|
|
{
|
|
tracing::warn!(
|
|
subagent_id, worktree_path = % wt_path.display(), error = % e,
|
|
"failed to remove pristine worktree for killed-while-pending subagent"
|
|
);
|
|
}
|
|
let result = SubagentResult {
|
|
success: false,
|
|
cancelled: true,
|
|
error: Some("Subagent was cancelled".to_string()),
|
|
subagent_id: subagent_id.to_string(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
duration_ms,
|
|
..Default::default()
|
|
};
|
|
update_subagent_meta_completed(subagent_meta_dir, &result);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
parent_session_id,
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id: subagent_id.to_string(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
status: result.status().to_string(),
|
|
error: result.error.clone(),
|
|
tool_calls: 0,
|
|
turns: 0,
|
|
duration_ms,
|
|
tokens_used: 0,
|
|
output: None,
|
|
will_wake: false,
|
|
},
|
|
parent_cmd_tx,
|
|
);
|
|
coordinator
|
|
.borrow_mut()
|
|
.move_pending_to_cancelled(subagent_id, "Subagent was cancelled");
|
|
let _ = request.result_tx.send(result);
|
|
}
|
|
fn emit_subagent_notification(
|
|
gateway: &GatewaySender,
|
|
parent_session_id: &str,
|
|
update: SessionUpdate,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
) {
|
|
let mut meta = None;
|
|
crate::util::event_id::ensure_event_id_meta(parent_session_id, &mut meta);
|
|
let notification = SessionNotification {
|
|
session_id: acp::SessionId::new(parent_session_id),
|
|
update,
|
|
meta: meta.map(serde_json::Value::Object),
|
|
};
|
|
if let Some(cmd_tx) = parent_cmd_tx {
|
|
let _ = cmd_tx.send(SessionCommand::XaiSessionNotification {
|
|
notification: notification.clone(),
|
|
});
|
|
}
|
|
let params = serde_json::to_value(¬ification)
|
|
.and_then(|v| serde_json::value::to_raw_value(&v))
|
|
.ok();
|
|
if let Some(params) = params {
|
|
let ext_notification =
|
|
acp::ExtNotification::new("kigi/session_notification", params.into());
|
|
gateway.forward_fire_and_forget(ext_notification);
|
|
}
|
|
}
|
|
/// Progress notification emission interval.
|
|
const PROGRESS_PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
|
/// Change signature for the progress-publisher dedupe:
|
|
/// `(turn_count, tool_call_count, context_usage_pct, error_count, tokens_used)`.
|
|
///
|
|
/// `tokens_used` is part of the signature so rising child token spend always
|
|
/// publishes a tick: goal token accounting (subagent records, live totals,
|
|
/// and the turn-end budget check) keys off prompt token movement, which can
|
|
/// climb while turn/tool counts and the coarse context-usage *percent* bucket
|
|
/// stay flat. Omitting it would stall those updates until the heartbeat or an
|
|
/// unrelated field moved.
|
|
type ProgressSignature = (u32, u32, u8, u32, u64);
|
|
/// Whether a progress tick should be emitted given the previous and current
|
|
/// [`ProgressSignature`]s. Emits on any change, or when `heartbeat_due`
|
|
/// forces a keep-alive after an idle gap.
|
|
fn progress_tick_should_emit(
|
|
prev: ProgressSignature,
|
|
cur: ProgressSignature,
|
|
heartbeat_due: bool,
|
|
) -> bool {
|
|
cur != prev || heartbeat_due
|
|
}
|
|
/// Parent-actor tick channel for [`spawn_progress_publisher`]: goal token
|
|
/// accounting is the only consumer, so a goal-disabled session sends no
|
|
/// per-tick commands at all.
|
|
fn goal_tick_cmd_tx(
|
|
goal_enabled: bool,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
) -> Option<mpsc::UnboundedSender<SessionCommand>> {
|
|
if goal_enabled {
|
|
parent_cmd_tx.cloned()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
/// Spawn a background task that periodically emits `SubagentProgress`
|
|
/// notifications on the parent session's notification channel.
|
|
///
|
|
/// The publisher samples the child's `SessionSignalsHandle` every
|
|
/// [`PROGRESS_PUBLISH_INTERVAL`] and emits a `SubagentProgress`
|
|
/// notification if the subagent is still running. It stops automatically
|
|
/// when `cancel_token` is cancelled (subagent completion/cancellation).
|
|
///
|
|
/// When `parent_cmd_tx` is `Some`, each tick is also delivered to the
|
|
/// parent `SessionActor` so goal mode can advance its live subagent
|
|
/// token accounting; the actor's `SubagentProgress` arm never persists
|
|
/// these ticks.
|
|
///
|
|
/// Notifications are **not** persisted to JSONL — they are transient UI
|
|
/// hints, not authoritative lifecycle events. The TUI can resync via
|
|
/// `kigi/subagent/list_running` on reconnect.
|
|
fn spawn_progress_publisher(
|
|
signals_handle: crate::session::signals::SessionSignalsHandle,
|
|
gateway: GatewaySender,
|
|
parent_session_id: String,
|
|
subagent_id: String,
|
|
child_session_id: String,
|
|
started_at: std::time::Instant,
|
|
cancel_token: tokio_util::sync::CancellationToken,
|
|
parent_cmd_tx: Option<mpsc::UnboundedSender<SessionCommand>>,
|
|
) {
|
|
tokio::task::spawn_local(async move {
|
|
let mut interval = tokio::time::interval(PROGRESS_PUBLISH_INTERVAL);
|
|
interval.tick().await;
|
|
let mut last_signature: ProgressSignature = (0, 0, 0, 0, 0);
|
|
let mut last_emit_at = tokio::time::Instant::now();
|
|
let heartbeat_max = tokio::time::Duration::from_secs(8);
|
|
loop {
|
|
tokio::select! {
|
|
_ = cancel_token.cancelled() => break, _ = interval.tick() => {}
|
|
}
|
|
let signals = match signals_handle.snapshot().await {
|
|
Some(s) => s,
|
|
None => break,
|
|
};
|
|
let sig: ProgressSignature = (
|
|
signals.turn_count,
|
|
signals.tool_call_count,
|
|
signals.context_window_usage,
|
|
signals.error_count,
|
|
signals.context_tokens_used,
|
|
);
|
|
let heartbeat_due = last_emit_at.elapsed() >= heartbeat_max;
|
|
if !progress_tick_should_emit(last_signature, sig, heartbeat_due) {
|
|
continue;
|
|
}
|
|
last_signature = sig;
|
|
last_emit_at = tokio::time::Instant::now();
|
|
let duration_ms = started_at.elapsed().as_millis() as u64;
|
|
let update = SessionUpdate::SubagentProgress {
|
|
subagent_id: subagent_id.clone(),
|
|
parent_session_id: parent_session_id.clone(),
|
|
child_session_id: child_session_id.clone(),
|
|
duration_ms,
|
|
turn_count: signals.turn_count,
|
|
tool_call_count: signals.tool_call_count,
|
|
tokens_used: signals.context_tokens_used,
|
|
context_window_tokens: signals.context_window_tokens,
|
|
context_usage_pct: signals.context_window_usage,
|
|
tools_used: signals.tools_used,
|
|
error_count: signals.error_count,
|
|
};
|
|
let notification = SessionNotification {
|
|
session_id: acp::SessionId::new(parent_session_id.clone()),
|
|
update,
|
|
meta: None,
|
|
};
|
|
let params = serde_json::to_value(¬ification)
|
|
.and_then(|v| serde_json::value::to_raw_value(&v))
|
|
.ok();
|
|
if let Some(ref cmd_tx) = parent_cmd_tx {
|
|
let _ = cmd_tx.send(SessionCommand::XaiSessionNotification { notification });
|
|
}
|
|
if let Some(params) = params {
|
|
let ext_notification =
|
|
acp::ExtNotification::new("kigi/session_notification", params.into());
|
|
gateway.forward_fire_and_forget(ext_notification);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
#[cfg(test)]
|
|
mod progress_publisher_tests {
|
|
use super::{ProgressSignature, progress_tick_should_emit};
|
|
const BASE: ProgressSignature = (3, 7, 12, 0, 30_000);
|
|
#[test]
|
|
fn token_only_change_emits() {
|
|
let cur: ProgressSignature = (3, 7, 12, 0, 45_000);
|
|
assert!(progress_tick_should_emit(BASE, cur, false));
|
|
}
|
|
#[test]
|
|
fn unchanged_without_heartbeat_skips() {
|
|
assert!(!progress_tick_should_emit(BASE, BASE, false));
|
|
}
|
|
#[test]
|
|
fn heartbeat_forces_emit_when_unchanged() {
|
|
assert!(progress_tick_should_emit(BASE, BASE, true));
|
|
}
|
|
}
|
|
/// Metadata stored as `meta.json` in the child session directory.
|
|
/// Links the child session back to its parent.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub(crate) struct SubagentMeta {
|
|
pub subagent_id: String,
|
|
pub parent_session_id: String,
|
|
pub child_session_id: String,
|
|
pub subagent_type: String,
|
|
pub description: String,
|
|
pub prompt: String,
|
|
/// "running" | "completed" | "failed" | "cancelled"
|
|
pub status: String,
|
|
pub started_at: chrono::DateTime<chrono::Utc>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub duration_ms: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_calls: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub turns: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
/// Effective context source after bootstrap: "new" or "resumed".
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub effective_context_source: Option<String>,
|
|
/// True only for a summarized (normalized) fork; false for verbatim
|
|
/// mirror-forks, resume, and new sessions.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub context_normalized: bool,
|
|
/// Error message if fork-copy failed and fell back to fresh.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub fork_copy_error: Option<String>,
|
|
/// Named persona applied to this subagent.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub persona: Option<String>,
|
|
/// ID of the source subagent this session was resumed from.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub resumed_from: Option<String>,
|
|
/// Effective cwd used by the child session. Persisted for durable
|
|
/// `resume_from` reconstruction after in-memory cache eviction.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub child_cwd: Option<String>,
|
|
/// Worktree path if the child used `isolation=worktree`. Persisted
|
|
/// for durable `resume_from` reconstruction.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub worktree_path: Option<String>,
|
|
/// Durable git ref holding a snapshot of the child's worktree working
|
|
/// state. Persisted so a deleted worktree can be rehydrated on resume.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub snapshot_ref: Option<String>,
|
|
/// Effective model ID used by the child session. Persisted for
|
|
/// durable `resume_from` identity validation.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub effective_model_id: Option<String>,
|
|
}
|
|
/// Write `meta.json`. Returns `true` on success so callers on the resume-pointer
|
|
/// path can gate worktree disposal on a durable write.
|
|
fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool {
|
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
|
tracing::warn!(error = % e, "failed to create subagent meta dir");
|
|
return false;
|
|
}
|
|
let meta_path = dir.join("meta.json");
|
|
let json = match serde_json::to_string_pretty(meta) {
|
|
Ok(json) => json,
|
|
Err(e) => {
|
|
tracing::warn!(error = % e, "failed to serialize subagent meta");
|
|
return false;
|
|
}
|
|
};
|
|
if let Err(e) = std::fs::write(&meta_path, json) {
|
|
tracing::warn!(error = % e, "failed to write subagent meta");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
/// Persist the durable worktree `snapshot_ref` into the on-disk `meta.json`
|
|
/// after completion, so `resumable_source_for` can rehydrate the disposed
|
|
/// worktree on resume. Returns `true` only when the ref is persisted to disk;
|
|
/// any read/parse/write failure is `warn!`-logged (this is the critical resume
|
|
/// pointer) so the caller keeps the worktree rather than removing it without a
|
|
/// recoverable ref. Also re-asserts the terminal `status` so a failed
|
|
/// `update_subagent_meta_completed` write can't leave a non-terminal record that
|
|
/// `resumable_source_for` rejects after the worktree is removed.
|
|
fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &str) -> bool {
|
|
let meta_path = dir.join("meta.json");
|
|
let mut meta = match std::fs::read_to_string(&meta_path) {
|
|
Ok(data) => match serde_json::from_str::<SubagentMeta>(&data) {
|
|
Ok(meta) => meta,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = % e,
|
|
"failed to parse subagent meta; snapshot_ref not persisted (resume pointer lost)"
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = % e,
|
|
"failed to read subagent meta; snapshot_ref not persisted (resume pointer lost)"
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
meta.snapshot_ref = Some(snapshot_ref.to_string());
|
|
meta.status = status.to_string();
|
|
write_subagent_meta(dir, &meta)
|
|
}
|
|
fn update_subagent_meta_completed(dir: &Path, result: &SubagentResult) {
|
|
let meta_path = dir.join("meta.json");
|
|
if let Ok(data) = std::fs::read_to_string(&meta_path)
|
|
&& let Ok(mut meta) = serde_json::from_str::<SubagentMeta>(&data)
|
|
{
|
|
meta.status = result.status().to_string();
|
|
meta.completed_at = Some(chrono::Utc::now());
|
|
meta.duration_ms = Some(result.duration_ms);
|
|
meta.tool_calls = Some(result.tool_calls);
|
|
meta.turns = Some(result.turns);
|
|
meta.error = result.error.clone();
|
|
write_subagent_meta(dir, &meta);
|
|
}
|
|
}
|
|
const ORPHAN_RECONCILE_REASON: &str = "interrupted by process restart";
|
|
/// `SubagentFinished` for a force-terminated orphan; interrupt counts are zeroed.
|
|
fn cancelled_orphan_finish(
|
|
subagent_id: String,
|
|
child_session_id: String,
|
|
duration_ms: u64,
|
|
) -> SessionUpdate {
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id,
|
|
child_session_id,
|
|
status: "cancelled".to_string(),
|
|
error: Some(ORPHAN_RECONCILE_REASON.to_string()),
|
|
tool_calls: 0,
|
|
turns: 0,
|
|
duration_ms,
|
|
tokens_used: 0,
|
|
output: None,
|
|
will_wake: false,
|
|
}
|
|
}
|
|
/// Flip a stale `running` meta to `cancelled` and emit the missing finish.
|
|
/// On meta-write failure returns `false` and skips the notify, so a reload re-heals.
|
|
fn finalize_orphaned_subagent(
|
|
subagent_meta_dir: &Path,
|
|
mut meta: SubagentMeta,
|
|
gateway: &GatewaySender,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
) -> bool {
|
|
let completed_at = chrono::Utc::now();
|
|
let duration_ms = (completed_at - meta.started_at).num_milliseconds().max(0) as u64;
|
|
meta.status = "cancelled".to_string();
|
|
meta.completed_at = Some(completed_at);
|
|
meta.duration_ms = Some(duration_ms);
|
|
meta.tool_calls = Some(0);
|
|
meta.turns = Some(0);
|
|
meta.error = Some(ORPHAN_RECONCILE_REASON.to_string());
|
|
if !write_subagent_meta(subagent_meta_dir, &meta) {
|
|
return false;
|
|
}
|
|
emit_subagent_notification(
|
|
gateway,
|
|
&meta.parent_session_id,
|
|
cancelled_orphan_finish(meta.subagent_id, meta.child_session_id, duration_ms),
|
|
parent_cmd_tx,
|
|
);
|
|
true
|
|
}
|
|
/// Parse `meta_path` and return it only when it is a stale `running` orphan
|
|
/// owned by `parent_session_id` and not tracked live. Malformed metas → `None`.
|
|
fn running_orphan_meta(
|
|
meta_path: &Path,
|
|
coordinator: &SubagentCoordinator,
|
|
parent_session_id: &str,
|
|
) -> Option<SubagentMeta> {
|
|
let data = std::fs::read_to_string(meta_path).ok()?;
|
|
let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
|
|
if meta.status != "running" || meta.parent_session_id != parent_session_id {
|
|
return None;
|
|
}
|
|
if coordinator.is_active_or_pending(&meta.subagent_id) {
|
|
return None;
|
|
}
|
|
Some(meta)
|
|
}
|
|
/// Heal subagents stuck "Running" after a dead process: emit exactly one
|
|
/// `SubagentFinished` per id, unioning two id-keyed sources (so a crash orphan
|
|
/// in both heals once) — `unfinished` replayed spawns whose finish a rewind
|
|
/// dropped (or a forked-in subagent with no meta), and on-disk `running` metas.
|
|
/// Skipping ids still active or pending: a `running` meta → `cancelled` (unless
|
|
/// the coordinator still holds its terminal result, then re-emit that); a terminal
|
|
/// meta that survived a rewound finish re-emits its real outcome; a no-meta
|
|
/// replayed spawn → `cancelled`. Runs after replay so the finish orders after the spawn.
|
|
pub(crate) fn reconcile_orphaned_subagents(
|
|
unfinished: &[(String, String)],
|
|
coordinator: &SubagentCoordinator,
|
|
session_dir: &Path,
|
|
parent_session_id: &str,
|
|
gateway: &GatewaySender,
|
|
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
|
|
) {
|
|
let subagents_dir = session_dir.join("subagents");
|
|
let mut candidates: std::collections::BTreeMap<String, Option<String>> =
|
|
std::collections::BTreeMap::new();
|
|
for (id, child) in unfinished {
|
|
candidates.insert(id.clone(), Some(child.clone()));
|
|
}
|
|
if let Ok(entries) = std::fs::read_dir(&subagents_dir) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
if running_orphan_meta(
|
|
&entry.path().join("meta.json"),
|
|
coordinator,
|
|
parent_session_id,
|
|
)
|
|
.is_some()
|
|
&& let Some(id) = name.to_str()
|
|
{
|
|
candidates.entry(id.to_string()).or_insert(None);
|
|
}
|
|
}
|
|
}
|
|
for (subagent_id, spawn_child) in candidates {
|
|
if coordinator.is_active_or_pending(&subagent_id) {
|
|
continue;
|
|
}
|
|
let subagent_dir = subagents_dir.join(&subagent_id);
|
|
let meta = std::fs::read_to_string(subagent_dir.join("meta.json"))
|
|
.ok()
|
|
.and_then(|data| serde_json::from_str::<SubagentMeta>(&data).ok());
|
|
match meta {
|
|
Some(m) if m.parent_session_id != parent_session_id => {}
|
|
Some(m) if m.status == "running" => {
|
|
if let Some(finish) = coordinator.completed_finish(&subagent_id) {
|
|
tracing::info!(
|
|
subagent_id = % subagent_id, parent_session_id,
|
|
"Re-emitting finish for completed subagent with a lost terminal meta write"
|
|
);
|
|
emit_subagent_notification(gateway, parent_session_id, finish, parent_cmd_tx);
|
|
} else {
|
|
tracing::info!(
|
|
subagent_id = % m.subagent_id, parent_session_id,
|
|
"Reconciling orphaned subagent left running by a previous process"
|
|
);
|
|
finalize_orphaned_subagent(&subagent_dir, m, gateway, parent_cmd_tx);
|
|
}
|
|
}
|
|
Some(m) => {
|
|
tracing::info!(
|
|
subagent_id = % subagent_id, parent_session_id, status = % m.status,
|
|
"Re-emitting finish for rewound subagent (terminal meta survived)"
|
|
);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
parent_session_id,
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id,
|
|
child_session_id: m.child_session_id,
|
|
status: m.status,
|
|
error: m.error,
|
|
tool_calls: m.tool_calls.unwrap_or(0),
|
|
turns: m.turns.unwrap_or(0),
|
|
duration_ms: m.duration_ms.unwrap_or(0),
|
|
tokens_used: 0,
|
|
output: None,
|
|
will_wake: false,
|
|
},
|
|
parent_cmd_tx,
|
|
);
|
|
}
|
|
None => {
|
|
let Some(child_session_id) = spawn_child else {
|
|
continue;
|
|
};
|
|
tracing::info!(
|
|
subagent_id = % subagent_id, parent_session_id,
|
|
"Reconciling inherited subagent with no local meta (cancelled)"
|
|
);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
parent_session_id,
|
|
cancelled_orphan_finish(subagent_id, child_session_id, 0),
|
|
parent_cmd_tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests;
|