//! Agent definition types — parsed from `.kigi/agents/*.md` files. use crate::error::AgentBuildError; use crate::prompt::context::TemplateOverride; use crate::prompt::user_message::UserMessageTemplate; use kigi_tools::implementations::codex; use kigi_tools::implementations::kigi; use kigi_tools::implementations::kigi_concise; use kigi_tools::implementations::memory; use kigi_tools::implementations::opencode; use kigi_tools::implementations::search_tool; use kigi_tools::implementations::use_tool; use kigi_tools::registry::types::{ToolConfig, ToolServerConfig}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use strum::{AsRefStr, Display, EnumIter, EnumString, IntoStaticStr}; /// Process-global registry of externally-provided toolset presets. /// /// # Visibility /// Each preset is registered as either **public** or **internal**: /// - **Public** presets are product presets: they are enumerated by /// [`preset_names`] / [`all_toolset_presets`] (so they appear in the /// workspace manifest, preset sets, etc.) *and* resolvable via /// [`toolset_for_preset`]. /// - **Internal** presets are resolved by name at runtime by the shell / /// orchestrator spawn path via [`toolset_for_preset`], but are deliberately /// NOT enumerated, so a harness-internal preset never leaks into public /// preset enumeration. /// /// # Ordering contract /// [`register_toolset_preset`] / [`register_internal_toolset_preset`] MUST run /// before the first preset resolution in the process. Presets registered later /// are still visible to subsequent `toolset_for_preset` / `preset_names` / /// `all_toolset_presets` calls, but any config resolved before registration /// will not see them. /// A toolset preset builder: a function producing a [`ToolServerConfig`]. pub type ToolsetPresetBuilder = fn() -> ToolServerConfig; /// Whether a registered preset is enumerated publicly or resolved by name only. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum PresetVisibility { /// A product preset: enumerated by `preset_names()` / `all_toolset_presets()`. Public, /// A harness-internal preset: resolvable by name but never enumerated. Internal, } static TOOLSET_PRESETS: OnceLock>> = OnceLock::new(); fn toolset_preset_registry() -> &'static Mutex> { TOOLSET_PRESETS.get_or_init(|| Mutex::new(HashMap::new())) } /// Register an out-of-tree **public** (product) toolset preset by name. Public /// presets are enumerated by [`preset_names`] / [`all_toolset_presets`] and /// resolvable via [`toolset_for_preset`]. See [`TOOLSET_PRESETS`]. pub fn register_toolset_preset(name: &str, builder: ToolsetPresetBuilder) { toolset_preset_registry() .lock() .expect("toolset preset registry poisoned") .insert(name.to_string(), (builder, PresetVisibility::Public)); } /// Register an out-of-tree **internal** toolset preset by name. Internal presets /// are resolvable via [`toolset_for_preset`] (the shell / orchestrator spawn /// path resolves them by name) but are deliberately NOT enumerated by /// [`preset_names`] / [`all_toolset_presets`], so they never leak into public /// preset enumeration (manifest generation, product preset sets, …). See /// [`TOOLSET_PRESETS`]. pub fn register_internal_toolset_preset(name: &str, builder: ToolsetPresetBuilder) { toolset_preset_registry() .lock() .expect("toolset preset registry poisoned") .insert(name.to_string(), (builder, PresetVisibility::Internal)); } /// Look up an externally-registered toolset preset by (already-normalized) name. /// Resolves BOTH public and internal presets. fn registered_toolset_preset(name: &str) -> Option { toolset_preset_registry() .lock() .expect("toolset preset registry poisoned") .get(name) .map(|(f, _)| f()) } /// Names of externally-registered **public** presets only (internal presets are /// intentionally excluded from enumeration). fn registered_public_toolset_preset_names() -> Vec { toolset_preset_registry() .lock() .expect("toolset preset registry poisoned") .iter() .filter(|(_, (_, visibility))| *visibility == PresetVisibility::Public) .map(|(name, _)| name.clone()) .collect() } /// Orchestrator-specific prompt body appended to the standard Kigi /// system prompt (`prompt.md`). Instructs the GBL model to delegate /// coding and exploration work to subagents. const ORCHESTRATOR_PROMPT_BODY: &str = "\ ## Orchestrator Mode You are a technical lead orchestrating a team of senior-engineer subagents. Your subagents \ are highly capable \u{2014} treat them as expert peers, not junior helpers. Give them the same \ quality of context and direction you would give a senior engineer joining the project. Your job is to think, plan, coordinate, and review. Their job is to explore, implement, \ and execute. Use them aggressively and liberally \u{2014} spawn subagents early and often. ### Your direct responsibilities: - High-level planning and architecture decisions - Reading files for quick context (${{ tools.by_kind.read }}, ${{ tools.by_kind.search }}, ${{ tools.by_kind.list }}) - Running quick terminal commands for orientation (${{ tools.by_kind.execute }}) - Invoking skills and MCP tools (${{ tools.by_kind.skill }}, ${{ tools.by_kind.search_tool }}, ${{ tools.by_kind.use_tool }}) - Web research (${{ tools.by_kind.web_search }}, ${{ tools.by_kind.web_fetch }}) - Asking the user questions (${{ tools.by_kind.ask_user }}) - Managing task lists and tracking progress (${{ tools.by_kind.plan }}) - Reviewing subagent results and synthesizing responses for the user ### ALWAYS delegate to subagents: - **ALL file modifications** \u{2014} creating, editing, deleting files (`general-purpose`) - **ALL builds, tests, and verification** \u{2014} running test suites, linters, compilers (`general-purpose`) - **Deep codebase exploration** \u{2014} searching across many files, understanding patterns (`explore`) - **Multi-step implementation** \u{2014} any task involving more than reading (`general-purpose`) - **Any research requiring thoroughness** \u{2014} don\u{2019}t do shallow searches yourself, spawn an `explore` subagent ### How to talk to subagents: Write prompts the way you would brief a senior engineer: - Explain WHAT you need done and WHY (the context behind the task) - Share what you already know \u{2014} file paths, function names, architectural decisions - Describe the end state, not step-by-step commands \u{2014} trust their judgment on HOW - If you have opinions on approach, share them as guidance, not rigid instructions - Include acceptance criteria: what does \"done\" look like? ### Parallelism: - Break independent tasks into separate subagents and run them in parallel - Use `explore` subagents to investigate multiple areas simultaneously - Launch implementation subagents for independent files/modules at the same time - Do NOT wait for one subagent before spawning others that don\u{2019}t depend on it ### Anti-patterns to avoid: - Do NOT do shallow 1-2 file reads yourself when an `explore` agent would be more thorough - Do NOT implement code changes yourself \u{2014} you have no file editing tools - Do NOT give subagents overly prescriptive step-by-step instructions \u{2014} trust their expertise - Do NOT summarize or re-explain what the user said \u{2014} get to work immediately"; /// Bash tool with clearer model-facing names: /// `run_terminal_cmd` → `run_terminal_command`, `is_background` → `background`. fn bash_tool_config() -> ToolConfig { ToolConfig::from(&kigi::BashTool) .with_name("run_terminal_command") .with_param_rename("is_background", "background") } /// Task/subagent tool with clearer model-facing names: /// `task` → `spawn_subagent`, `run_in_background` → `background`. fn task_tool_config() -> ToolConfig { ToolConfig::from(&kigi::TaskTool) .with_name("spawn_subagent") .with_param_rename("run_in_background", "background") } /// Task output tool renamed for clarity: /// `get_task_output` → `get_command_or_subagent_output`. fn task_output_tool_config() -> ToolConfig { ToolConfig::from(&kigi::TaskOutputTool).with_name("get_command_or_subagent_output") } /// `wait_tasks` → `wait_commands_or_subagents`. fn wait_tasks_tool_config() -> ToolConfig { ToolConfig::from(&kigi::WaitTasksTool).with_name("wait_commands_or_subagents") } /// `kill_task` → `kill_command_or_subagent`. fn kill_task_tool_config() -> ToolConfig { ToolConfig::from(&kigi::KillTaskTool).with_name("kill_command_or_subagent") } /// Complete workspace-executable toolset for hub registration. /// /// Extends `default_kigi_toolset()` with tools that are dynamically /// injected by `AgentBuilder::build()` or only available in specific modes. /// In proxy mode, the workspace server executes ALL tools — the shell has /// zero local dispatch. pub fn workspace_kigi_toolset() -> ToolServerConfig { let mut tools = default_kigi_toolset().tools; tools.push((&opencode::OpenCodeWriteTool).into()); tools.push((&kigi::EnterPlanModeTool).into()); tools.push((&kigi::ExitPlanModeTool).into()); tools.push((&kigi::AskUserQuestionTool).into()); tools.push((&kigi::WebSearchTool).into()); tools.push((&kigi::WebFetchTool).into()); tools.push((&memory::search_tool::MemorySearchImpl).into()); tools.push((&memory::get_tool::MemoryGetImpl).into()); tools.push((&kigi::LspTool).into()); ToolServerConfig { tools, behavior_preset: None, } } /// Toolset for the `kigi-computer` (workspace/sandbox) preset. fn kigi_computer_toolset() -> ToolServerConfig { #[allow(unused_mut)] let mut tools = vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::SearchReplaceTool).into(), (&opencode::OpenCodeWriteTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), (&kigi::KillTerminalCommandTool).into(), (&kigi::GetTerminalCommandOutputTool).into(), ]; ToolServerConfig { tools, behavior_preset: None, } } /// Every named toolset preset, as `(normalized_name, config)` pairs. /// /// Single source of truth: [`toolset_for_preset`] resolves through this /// table, and the preset-coverage tests iterate it, so a new preset is /// automatically covered the moment it becomes resolvable. /// Native (in-crate) toolset presets. fn native_toolset_presets() -> Vec<(&'static str, ToolServerConfig)> { vec![ ("kigi", workspace_kigi_toolset()), ("kigi-concise", kigi_concise_toolset()), ("kigi-plan", kigi_plan_toolset()), ("codex", codex_toolset()), ("explore", explore_toolset()), ("plan", plan_toolset()), ("kigi-computer", kigi_computer_toolset()), ] } /// Every named **public** toolset preset (native + externally registered public /// presets), as `(name, config)` pairs. Harness-internal registered presets are /// intentionally excluded — resolve them by name via [`toolset_for_preset`]. fn all_toolset_presets() -> Vec<(String, ToolServerConfig)> { let mut out: Vec<(String, ToolServerConfig)> = native_toolset_presets() .into_iter() .map(|(name, cfg)| (name.to_string(), cfg)) .collect(); for name in registered_public_toolset_preset_names() { if !out.iter().any(|(n, _)| *n == name) && let Some(cfg) = registered_toolset_preset(&name) { out.push((name, cfg)); } } out } /// Names of every named toolset preset (native first, then registered). pub fn preset_names() -> Vec { all_toolset_presets() .into_iter() .map(|(name, _)| name) .collect() } /// Resolve a named toolset preset to its [`ToolServerConfig`], or `None` if unknown. pub fn toolset_for_preset(preset: &str) -> Option { let normalized = preset.trim().to_ascii_lowercase().replace([' ', '_'], "-"); native_toolset_presets() .into_iter() .find(|(name, _)| *name == normalized) .map(|(_, toolset)| toolset) .or_else(|| registered_toolset_preset(&normalized)) } fn default_kigi_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::SearchReplaceTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), wait_tasks_tool_config(), task_tool_config(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::UpdateGoalTool).into(), ], behavior_preset: None, } } fn kigi_concise_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ (&kigi_concise::BashConciseTool).into(), (&kigi_concise::ReadFileConciseTool).into(), (&kigi_concise::SearchReplaceConciseTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&kigi::UpdateGoalTool).into(), ], behavior_preset: None, } } /// Hashline toolset: anchor-based read/edit/search + standard utilities. /// /// `hashline_tools` should be the 3 hashline `ToolConfig` entries produced by /// `FileToolset::Hashline.tool_configs(&hashline_config)` — they carry the /// scheme parameters as tool params. pub fn kigi_hashline_toolset( hashline_tools: Vec, ) -> ToolServerConfig { let mut tools: Vec = vec![bash_tool_config()]; tools.extend(hashline_tools); tools.extend([ (&kigi::ListDirTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), wait_tasks_tool_config(), task_tool_config(), (&kigi::WebSearchTool).into(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::UpdateGoalTool).into(), ]); ToolServerConfig { tools, behavior_preset: None, } } fn codex_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&codex::CodexReadFileTool).into(), (&codex::ApplyPatchTool).into(), (&codex::CodexListDirTool).into(), (&codex::CodexGrepFilesTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), ], behavior_preset: None, } } /// Read-only toolset for the **explore** subagent. /// /// Genuinely read-only: `read_file` (Read), `list_dir` (Glob), `grep` (Grep). /// `run_terminal_command` (Bash) is intentionally omitted so exploration cannot /// mutate the workspace — the read-only guarantee is enforced by the toolset, /// not merely by the prompt. With no `BashTool`, the background-task helpers /// (`KillTaskTool`/`TaskOutputTool`) are unnecessary and also omitted. fn explore_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ (&kigi::ReadFileTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), ], behavior_preset: None, } } /// Plan-mode toolset — read-only inspection tools, no shell, no file-editing. /// /// Enforces read-only at the toolset: the agent may inspect the repo and keep /// a todo list, but `search_replace` (file edits) and `run_terminal_command` /// (shell) are both omitted so it cannot mutate the workspace. fn plan_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ (&kigi::ReadFileTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), (&kigi::TodoWriteTool).into(), ], behavior_preset: None, } } /// Kigi + plan mode toolset. /// /// Extends the default `kigi` toolset with plan mode tools: /// `enter_plan_mode`, `exit_plan_mode`, and `ask_user_question`. /// This allows the agent to enter a structured planning phase before /// writing code, with user-approved plans. fn kigi_plan_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::SearchReplaceTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), task_tool_config(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::UpdateGoalTool).into(), (&kigi::EnterPlanModeTool).into(), (&kigi::ExitPlanModeTool).into(), (&kigi::AskUserQuestionTool).into(), ], behavior_preset: None, } } /// Orchestrator toolset: read/search/orchestration tools only. /// /// No terminal execution, no file editing. The orchestrator delegates /// all execution and file modification to subagents. Retains read_file, /// grep, list_dir for research, plus the full subagent/skill/MCP/plan /// stack for orchestration. fn orchestrator_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), task_tool_config(), task_output_tool_config(), wait_tasks_tool_config(), kill_task_tool_config(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::TodoWriteTool).into(), (&kigi::EnterPlanModeTool).into(), (&kigi::ExitPlanModeTool).into(), (&kigi::AskUserQuestionTool).into(), (&kigi::UpdateGoalTool).into(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&kigi::WebSearchTool).into(), (&kigi::WebFetchTool).into(), (&memory::MemorySearchImpl).into(), (&memory::MemoryGetImpl).into(), ], behavior_preset: None, } } /// Kigi + plan mode toolset WITHOUT subagent tools. /// /// Same as `kigi_plan_toolset` but excludes `TaskTool`, /// `TaskOutputTool`, and `KillTaskTool`. Use this when the shell /// does not have subagent infrastructure wired up. fn kigi_plan_no_subagents_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::SearchReplaceTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::UpdateGoalTool).into(), (&kigi::EnterPlanModeTool).into(), (&kigi::ExitPlanModeTool).into(), (&kigi::AskUserQuestionTool).into(), ], behavior_preset: None, } } /// Default Kigi toolset + `ask_user_question`. /// /// Same as `default_kigi_toolset` with the `AskUserQuestionTool` added, /// allowing the agent to ask structured questions without full plan mode. fn kigi_ask_user_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ bash_tool_config(), (&kigi::ReadFileTool).into(), (&kigi::SearchReplaceTool).into(), (&kigi::ListDirTool).into(), (&kigi::GrepTool).into(), kill_task_tool_config(), (&kigi::TodoWriteTool).into(), task_output_tool_config(), wait_tasks_tool_config(), task_tool_config(), (&kigi::SchedulerCreateTool).into(), (&kigi::SchedulerDeleteTool).into(), (&kigi::SchedulerListTool).into(), (&kigi::MonitorTool).into(), (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&kigi::UpdateGoalTool).into(), (&kigi::AskUserQuestionTool).into(), ], behavior_preset: None, } } fn opencode_toolset() -> ToolServerConfig { ToolServerConfig { tools: vec![ (&opencode::OpenCodeBashTool).into(), (&opencode::OpenCodeReadTool).into(), (&opencode::OpenCodeEditTool).into(), (&opencode::OpenCodeWriteTool).into(), (&opencode::OpenCodeGrepTool).into(), (&opencode::OpenCodeGlobTool).into(), (&opencode::OpenCodeTodoWriteTool).into(), (&opencode::OpenCodeSkillTool).into(), kill_task_tool_config(), task_output_tool_config(), ], behavior_preset: None, } } /// Model override for an agent definition. /// /// Two states: /// - `Inherit` — use the parent session's model (default). /// - `Override(String)` — use a specific model ID, resolved against /// available models at subagent spawn time. /// /// In YAML frontmatter / JSON: /// - `model: inherit` or omitted → `Inherit` /// - `model: kigi-3-fast` → `Override("kigi-3-fast")` #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ModelOverride { /// Use the parent session's model. #[default] Inherit, /// Use a specific model ID. Override(String), } impl std::fmt::Display for ModelOverride { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Inherit => f.write_str("inherit"), Self::Override(id) => f.write_str(id), } } } impl<'de> Deserialize<'de> for ModelOverride { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(Self::Inherit), Some(s) if s.is_empty() || s.eq_ignore_ascii_case("inherit") => Ok(Self::Inherit), Some(s) => Ok(Self::Override(s)), } } } impl serde::Serialize for ModelOverride { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { Self::Inherit => serializer.serialize_str("inherit"), Self::Override(id) => serializer.serialize_str(id), } } } const AGENT_TASK_KEYWORDS: &str = "Agent|Task"; /// Splits `"Agent(a, b), read_file"` → `["Agent(a, b)", "read_file"]`. pub static AGENT_TASK_TOKENIZER_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { regex::Regex::new(&format!(r"(?i:{AGENT_TASK_KEYWORDS})\([^)]*\)|[^,]+")) .expect("valid regex") }); /// Matches `"Agent(a, b)"` and captures `"a, b"` in group 1. `None` for bare `Agent`. pub static AGENT_TASK_CLASSIFIER_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { regex::Regex::new(&format!(r"^(?i:{AGENT_TASK_KEYWORDS})(?:\(([^)]*)\))?$")) .expect("valid regex") }); /// Accepts `"a, b, c"` or `["a", "b"]`. Trims whitespace. fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { use serde::de; struct StringOrVec; impl<'de> de::Visitor<'de> for StringOrVec { type Value = Vec; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a comma-separated string or an array of strings") } fn visit_str(self, s: &str) -> Result { Ok(AGENT_TASK_TOKENIZER_RE .find_iter(s) .map(|m| m.as_str().trim().to_string()) .filter(|s| !s.is_empty()) .collect()) } fn visit_seq>(self, mut seq: A) -> Result { let mut v = Vec::new(); while let Some(item) = seq.next_element::()? { v.push(item); } Ok(v) } fn visit_none(self) -> Result { Ok(Vec::new()) } fn visit_unit(self) -> Result { Ok(Vec::new()) } } deserializer.deserialize_any(StringOrVec) } /// Accepts a positive u32 or null/absent. Rejects 0. fn deserialize_nonzero_u32<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let opt = Option::::deserialize(deserializer)?; if let Some(0) = opt { return Err(serde::de::Error::custom("maxTurns must be greater than 0")); } Ok(opt) } /// All built-in agent names as a typed enum. /// /// Eliminates string matching in discovery and ensures built-in names /// are defined in exactly one place. The enum covers all built-in /// agents for centralized name management and `by_name()` dispatch. /// /// `subagent_variants()` returns only the 3 that are exposed to the LLM /// via the `TaskTool` description. The remaining 6 are top-level agent /// profiles resolvable by name but not advertised as subagent types. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, EnumIter, AsRefStr, IntoStaticStr, )] #[strum(serialize_all = "kebab-case")] pub enum BuiltinAgentName { Kigi, KigiConcise, KigiPlan, KigiPlanNoSubagents, KigiAskUser, Codex, Opencode, GeneralPurpose, Explore, Plan, BrowserUse, #[strum(serialize = "kigi-orchestrator")] KigiOrchestrator, } /// Agent-type prefix written by pre-rebrand builds into session files and /// model configs (``, `-plan`, `-concise`, …). /// /// DOCUMENTED ALIAS: sessions persist agent types, so names written by older /// builds must keep resolving. The prefix is assembled with `concat!` so the /// release-gate grep over Rust sources (which forbids the upstream brand /// string) stays clean while the runtime value is the historical spelling. pub const LEGACY_AGENT_TYPE_PREFIX: &str = concat!("gr", "ok-build"); /// Map a legacy pre-rebrand agent-type name onto its current `kigi*` /// equivalent: `` → `kigi`, `-plan` → `kigi-plan`, and so on. /// Non-legacy names pass through unchanged. Applied at the name-resolution /// boundaries ([`crate::discovery::by_name`] and friends, /// [`is_strict_harness_agent_type`]) so persisted agent types from old /// session files keep loading. pub fn canonical_agent_type(name: &str) -> std::borrow::Cow<'_, str> { match name.strip_prefix(LEGACY_AGENT_TYPE_PREFIX) { Some("") => std::borrow::Cow::Borrowed("kigi"), Some(rest) if rest.starts_with('-') => std::borrow::Cow::Owned(format!("kigi{rest}")), _ => std::borrow::Cow::Borrowed(name), } } /// Strict-harness predicate by name. Resolves via `BuiltinAgentName` and /// delegates to [`AgentDefinition::is_strict_harness`]; unknown names /// return `false` (conservative — never enforce a harness we can't verify). /// Accepts legacy pre-rebrand agent-type names via [`canonical_agent_type`]. /// Callers that already hold an `AgentDefinition` should call that method /// directly so project-level shadowing is honored. pub fn is_strict_harness_agent_type(name: &str) -> bool { use std::str::FromStr; BuiltinAgentName::from_str(canonical_agent_type(name).as_ref()) .map(|b| b.definition().is_strict_harness()) .unwrap_or(false) } impl BuiltinAgentName { /// Build the `AgentDefinition` for this built-in agent. pub fn definition(self) -> AgentDefinition { match self { Self::Kigi => AgentDefinition::default_kigi(), Self::KigiConcise => AgentDefinition::kigi_concise(), Self::KigiPlan => AgentDefinition::kigi_plan(), Self::KigiPlanNoSubagents => AgentDefinition::kigi_plan_no_subagents(), Self::KigiAskUser => AgentDefinition::kigi_ask_user(), Self::Codex => AgentDefinition::codex(), Self::Opencode => AgentDefinition::opencode(), Self::GeneralPurpose => AgentDefinition::general_purpose(), Self::Explore => AgentDefinition::explore(), Self::Plan => AgentDefinition::plan(), Self::BrowserUse => AgentDefinition::browser_use(), Self::KigiOrchestrator => AgentDefinition::kigi_orchestrator(), } } /// Built-in agents available as subagents via the Task tool. pub fn subagent_variants() -> &'static [Self] { &[Self::GeneralPurpose, Self::Explore, Self::Plan] } } /// Portable agent identity — parsed from .kigi/agents/*.md. /// Usable as both a top-level agent and a subagent definition. /// /// This is the stable, version-controllable contract. It does NOT /// contain session-level policies (compaction, system reminders). /// Those are provided by the AgentBuilder at build time. #[derive(Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentDefinition { pub name: String, pub description: String, /// Plugin namespace for plugin-backed agents only. #[serde(skip)] pub plugin_name: Option, #[serde(default = "default_prompt_mode")] pub prompt_mode: PromptMode, #[serde(default = "default_kigi_toolset")] pub tool_config: ToolServerConfig, /// Runtime capability mode that constrains which tool kinds the agent /// can use. Applied during subagent spawn in `handle_subagent_request` /// by filtering the definition's `tool_config` before session creation. #[serde(default, skip_serializing_if = "Option::is_none")] pub capability_mode: Option, #[serde(default)] pub permission_mode: PermissionMode, #[serde(default)] pub skills: Vec, /// When true (the default), the AgentBuilder discovers skills from CWD /// at build time and seeds mid-session skill discovery. When false, /// skill discovery is suppressed and the agent gets an empty skill /// list with no CWD-based runtime discovery. #[serde(default = "default_true")] pub discover_skills: bool, /// Whether to inherit the parent session's discovered skills when /// spawned as a subagent. Ignored for primary sessions. #[serde(default = "default_true")] pub inherit_skills: bool, #[serde(default = "default_true")] pub agents_md: bool, /// When true (the default), the AgentBuilder layers session-level optional /// tools on top of the agent's declared `tool_config`: memory_search/get, /// web_search, web_fetch, lsp, OpenCode write /// fallback, and the plan-mode tools. /// /// Set this to `false` for harnesses that need an exact, minimal toolset /// (e.g. the compat harness, where every advertised tool must match the /// model's trained schema). The agent's `tool_config` is then used /// verbatim with only the subagent strip applied. #[serde(default = "default_true")] pub inject_default_tools: bool, /// Tool allowlist. Empty = inherit all. Also carries `Agent(type)` directives. #[serde(default, deserialize_with = "deserialize_string_or_vec")] pub tools: Vec, /// Tool denylist. `Agent(type)` entries strip spawn permissions. #[serde(default, deserialize_with = "deserialize_string_or_vec")] pub disallowed_tools: Vec, #[serde(default)] pub effort: Option, #[serde(default, deserialize_with = "deserialize_nonzero_u32")] pub max_turns: Option, #[serde(default)] pub isolation: Option, #[serde(default)] pub background: Option, #[serde(default)] pub color: Option, #[serde(default)] pub initial_prompt: Option, #[serde(default)] pub mcp_servers: Vec, #[serde(default)] pub mcp_inheritance: McpInheritance, #[serde(default, deserialize_with = "deserialize_hooks_map")] pub hooks: Option, #[serde(default)] pub memory: Option, #[serde(default)] pub model: ModelOverride, /// Completion requirement — declares that this agent must call a /// specific tool before the turn ends. #[serde(default)] pub completion_requirement: Option, /// Subagent types this agent can spawn (derived by builder from `tools`). /// `None` = unrestricted, `Some([t1])` = restricted, `Some([])` = blocked. #[serde(skip)] pub allowed_subagent_types: Option>, /// Session-operator tool restrictions (`--tools` / `--disallowed-tools`), /// distinct from the agent author's own `tools`/`disallowed_tools`. The /// builder applies them as a final clamp over the fully-assembled toolset /// (function + hosted), so they bind regardless of later `tool_config` /// mutations and compose with the agent's own filters by intersection. /// `None` = no session restriction. #[serde(skip)] pub session_tools_allowlist: Option>, #[serde(skip)] pub session_tools_denylist: Option>, #[serde(skip)] pub prompt_body: Option, #[serde(skip)] pub system_prompt: TemplateOverride, /// First-user-message template selector. `Default` (the default) lets /// the shell layer build the legacy `` + `` /// prefix; `Custom` uses a caller-supplied template string. #[serde(default)] pub user_message_template: UserMessageTemplate, /// Where this definition was loaded from, optional if built in agent definition #[serde(skip)] pub source_path: Option, /// Discovery scope (project vs user). #[serde(skip)] pub scope: AgentScope, } /// Declares that the agent must call a specific tool before the turn ends. #[derive(Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct CompletionRequirement { /// Canonical tool name that must be called. pub tool: String, /// Reminder text injected when the tool hasn't been called. pub reminder: String, /// Suggested recovery policy for the harness. #[serde(default)] pub recovery: Option, } /// Suggested turn-level recovery policy. #[derive(Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct RecoveryPolicy { pub max_retries: u32, pub base_delay_ms: u64, pub max_delay_ms: u64, } /// Per-tool execution config. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecConfig { /// Retry config for this tool. None = no retry (execute once). #[serde(default)] pub retry: Option, } /// Retry configuration for a single tool. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolRetryConfig { pub max_retries: u32, pub base_delay_ms: u64, pub max_delay_ms: u64, } /// How the Markdown body interacts with the base prompt template. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PromptMode { /// Body is appended to the base template (tool conventions, /// formatting rules, user_info). Default. #[default] Extend, /// Body IS the complete system prompt. No base template. Full, } fn default_prompt_mode() -> PromptMode { PromptMode::Extend } /// Where the agent definition was discovered. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AgentScope { /// .kigi/agents/ (project-level, highest priority) Project, /// ~/.kigi/agents/ (user-level) User, /// ~/.kigi/bundled/agents/ (lowest-priority bundled cache) Bundled, /// Built-in agent (e.g., default_kigi(), browser_use()). #[default] BuiltIn, } impl AgentScope { pub fn label(self) -> &'static str { match self { Self::Project => "project", Self::User => "user", Self::Bundled => "bundled", Self::BuiltIn => "built-in", } } } impl std::fmt::Display for AgentScope { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.label()) } } /// Controls which parent MCP servers a subagent inherits. /// /// Deserializes from: /// - `"all"` / `"none"` (string, case-insensitive) /// - `{ "named": ["slack", "github"] }` / `{ "except": ["internal"] }` (map) /// /// The custom `Deserialize` is needed because `serde_yaml` 0.9 uses YAML /// tags (`!named`) for externally-tagged enum data variants, but agent /// definition frontmatter uses the mapping style that JSON also expects. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub enum McpInheritance { #[default] All, None, Named(Vec), Except(Vec), } impl<'de> Deserialize<'de> for McpInheritance { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { use serde::de; struct McpInheritanceVisitor; impl<'de> de::Visitor<'de> for McpInheritanceVisitor { type Value = McpInheritance; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str(r#""all", "none", {"named": [...]}, or {"except": [...]}"#) } fn visit_str(self, s: &str) -> Result { match s.to_ascii_lowercase().as_str() { "all" => Ok(McpInheritance::All), "none" => Ok(McpInheritance::None), other => Err(de::Error::unknown_variant(other, &["all", "none"])), } } fn visit_map>(self, mut map: A) -> Result { let key: String = map .next_key()? .ok_or_else(|| de::Error::invalid_length(0, &"a single-key map"))?; let value: Vec = map.next_value()?; if map.next_key::()?.is_some() { return Err(de::Error::custom( "mcpInheritance map must have exactly one key", )); } match key.as_str() { "named" => Ok(McpInheritance::Named(value)), "except" => Ok(McpInheritance::Except(value)), other => Err(de::Error::unknown_variant(other, &["named", "except"])), } } } deserializer.deserialize_any(McpInheritanceVisitor) } } /// Permission mode. Only `BypassPermissions` is wired at spawn; others are forward-compat. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, serde::Serialize, strum::EnumCount)] #[serde(rename_all = "camelCase")] pub enum PermissionMode { #[default] Default, AcceptEdits, /// Background classifier reviews tool calls. Auto, /// Silently deny non-pre-approved tools. DontAsk, BypassPermissions, Plan, } impl PermissionMode { pub const VALID_VALUES: &[&str] = &[ "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions", "plan", ]; } const _: () = assert!(PermissionMode::VALID_VALUES.len() == ::COUNT); #[derive( Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize, IntoStaticStr, strum::EnumCount, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Effort { Low, Medium, High, #[serde(rename = "xhigh")] #[strum(serialize = "xhigh")] XHigh, Max, } impl Effort { pub const VALID_VALUES: &[&str] = &["low", "medium", "high", "xhigh", "max"]; } const _: () = assert!(Effort::VALID_VALUES.len() == ::COUNT); #[derive( Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize, IntoStaticStr, strum::EnumCount, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum IsolationMode { None, Worktree, } impl IsolationMode { pub const VALID_VALUES: &[&str] = &["none", "worktree"]; } const _: () = assert!(IsolationMode::VALID_VALUES.len() == ::COUNT); #[derive( Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize, IntoStaticStr, strum::EnumCount, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum AgentColor { Red, Blue, Green, Yellow, Purple, Orange, Pink, Cyan, } impl AgentColor { pub const VALID_VALUES: &[&str] = &[ "red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan", ]; } const _: () = assert!(AgentColor::VALID_VALUES.len() == ::COUNT); /// Agent memory scope. Distinct from `storage::MemoryScope` (global-vs-workspace write target). #[derive( Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize, IntoStaticStr, strum::EnumCount, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum MemoryScope { /// `~/.kigi/agent-memory//` User, /// `/.kigi/agent-memory//` Project, /// `/.kigi/agent-memory-local//` Local, } impl MemoryScope { pub const VALID_VALUES: &[&str] = &["user", "project", "local"]; } const _: () = assert!(MemoryScope::VALID_VALUES.len() == ::COUNT); #[derive(Debug)] pub struct ResolvedMemoryDir { pub path: std::path::PathBuf, /// No workspace hash needed (already project-scoped). pub is_project_scoped: bool, } impl MemoryScope { pub fn resolve_dir(self, agent_name: &str, project_cwd: &std::path::Path) -> ResolvedMemoryDir { match self { Self::User => ResolvedMemoryDir { path: kigi_config::kigi_home() .join("agent-memory") .join(agent_name), is_project_scoped: false, }, Self::Project => ResolvedMemoryDir { path: project_cwd.join(".kigi/agent-memory").join(agent_name), is_project_scoped: true, }, Self::Local => ResolvedMemoryDir { path: project_cwd .join(".kigi/agent-memory-local") .join(agent_name), is_project_scoped: true, }, } } } /// Hooks config validated as an object at parse time. Semantic parsing deferred to spawn. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct HooksConfig(pub serde_json::Map); impl HooksConfig { pub fn as_value(&self) -> serde_json::Value { serde_json::Value::Object(self.0.clone()) } } fn deserialize_hooks_map<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(serde_json::Value::Object(map)) => Ok(Some(HooksConfig(map))), Some(_) => Err(serde::de::Error::custom("hooks must be an object")), } } /// MCP server reference — typed to catch config errors at parse time. #[derive(Debug, Clone, PartialEq, Eq)] pub enum McpServerRef { Named(String), /// Opaque JSON config resolved to `McpServer` at spawn time. Inline { name: String, config: serde_json::Value, }, } impl<'de> Deserialize<'de> for McpServerRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let val = serde_json::Value::deserialize(deserializer)?; match val { serde_json::Value::String(s) => Ok(McpServerRef::Named(s)), serde_json::Value::Object(obj) if obj.len() == 1 => { let (name, config) = obj.into_iter().next().unwrap(); if !config.is_object() { return Err(serde::de::Error::custom(format!( "mcpServers inline config for '{name}' must be an object" ))); } Ok(McpServerRef::Inline { name, config }) } serde_json::Value::Object(obj) => { if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { Ok(McpServerRef::Inline { name: name.to_string(), config: serde_json::Value::Object(obj), }) } else { Err(serde::de::Error::custom( "mcpServers entry must be a string, a {name: config} map, \ or an object with a 'name' field", )) } } _ => Err(serde::de::Error::custom( "mcpServers entry must be a string or object", )), } } } impl serde::Serialize for McpServerRef { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { McpServerRef::Named(s) => serializer.serialize_str(s), McpServerRef::Inline { name, config } => { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(1))?; map.serialize_entry(name, config)?; map.end() } } } } /// Bash tool config overrides (agent-definition layer). /// /// NOTE: Uses `camelCase` for YAML frontmatter. The `AgentBuilder` maps /// these into `kigi_tools::registry::types::ToolsetConfig.bash` /// which uses the tools crate's `BashToolConfig` type. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BashConfig { #[serde(default = "default_timeout_secs")] pub timeout_secs: f64, #[serde(default = "default_output_byte_limit")] pub output_byte_limit: usize, #[serde(default)] pub cmd_prefix: Option, } impl Default for BashConfig { fn default() -> Self { Self { timeout_secs: default_timeout_secs(), output_byte_limit: default_output_byte_limit(), cmd_prefix: None, } } } fn default_timeout_secs() -> f64 { 120.0 } fn default_output_byte_limit() -> usize { 200_000 } fn default_true() -> bool { true } /// Strip a tool id's `Namespace:` prefix, yielding its short name. pub(crate) fn short_tool_name(id: &str) -> &str { id.rsplit(':').next().unwrap_or(id) } /// Whether an allow/deny `entry` refers to tool `id` (by full id or short name). pub(crate) fn tool_id_eq(entry: &str, id: &str) -> bool { entry == id || entry == short_tool_name(id) } /// Whether any `list` entry refers to tool `id`. pub(crate) fn tool_id_matches(list: &[String], id: &str) -> bool { list.iter().any(|e| tool_id_eq(e, id)) } impl AgentDefinition { /// Parse an agent definition from a Markdown file with YAML frontmatter. /// /// File format: /// ```text /// --- /// name: my-agent /// description: A custom agent /// # ... other fields /// --- /// /// System prompt body goes here... /// ``` pub fn from_file(path: impl AsRef) -> Result { let path = path.as_ref(); let content = std::fs::read_to_string(path).map_err(AgentBuildError::IoError)?; let mut def = Self::parse(&content)?; def.source_path = Some(path.to_path_buf()); def.plugin_name = None; def.scope = Self::scope_from_path(path); Ok(def) } /// Parse only YAML frontmatter from an agent file, leaving prompt_body unset. pub fn from_file_frontmatter_only(path: impl AsRef) -> Result { let path = path.as_ref(); let content = std::fs::read_to_string(path).map_err(AgentBuildError::IoError)?; let trimmed = content.trim_start(); if !trimmed.starts_with("---") { return Err(AgentBuildError::ParseError( "missing frontmatter delimiters".to_string(), )); } let after_opening = &trimmed[3..]; let closing_idx = after_opening.find("\n---").ok_or_else(|| { AgentBuildError::ParseError("missing closing frontmatter delimiter".to_string()) })?; let yaml_content = &after_opening[..closing_idx]; let mut def: AgentDefinition = serde_yaml::from_str(yaml_content) .map_err(|e| AgentBuildError::ParseError(e.to_string()))?; def.prompt_body = None; def.system_prompt = TemplateOverride::None; def.source_path = Some(path.to_path_buf()); def.plugin_name = None; def.scope = Self::scope_from_path(path); Ok(def) } /// Parse from string content (for testing and inline definitions). pub fn parse(content: &str) -> Result { let trimmed = content.trim_start(); if !trimmed.starts_with("---") { return Err(AgentBuildError::ParseError( "missing frontmatter delimiters".to_string(), )); } let after_opening = &trimmed[3..]; let closing_idx = after_opening.find("\n---").ok_or_else(|| { AgentBuildError::ParseError("missing closing frontmatter delimiter".to_string()) })?; let yaml_content = &after_opening[..closing_idx]; let after_closing = &after_opening[closing_idx + 4..]; let body_start = after_closing.find('\n').map(|i| i + 1).unwrap_or(0); let body = after_closing[body_start..].trim(); let prompt_body = if body.is_empty() { None } else { Some(body.to_string()) }; let mut def: AgentDefinition = serde_yaml::from_str(yaml_content) .map_err(|e| AgentBuildError::ParseError(e.to_string()))?; def.prompt_body = prompt_body; def.plugin_name = None; Ok(def) } /// Determine the scope of a definition file based on its path. fn scope_from_path(path: &Path) -> AgentScope { let path_str = path.to_string_lossy(); let kigi = kigi_config::user_kigi_home(); let home = dirs::home_dir(); for (dir, scope) in crate::discovery::user_agent_dirs(home.as_deref(), kigi.as_deref()) { if path.starts_with(&dir) { return scope; } } if path_str.contains(".kigi/agents/") || path_str.contains(".kigi\\agents\\") { return AgentScope::Project; } if path_str.contains(".kigi/bundled/agents/") || path_str.contains(".kigi\\bundled\\agents\\") { return AgentScope::Bundled; } AgentScope::BuiltIn } } impl AgentDefinition { /// Whether `id` passes the session-operator clamp: denylist wins, then an /// unset allowlist allows all. pub(crate) fn session_tools_allowed(&self, id: &str) -> bool { if self .session_tools_denylist .as_deref() .is_some_and(|d| tool_id_matches(d, id)) { return false; } self.session_tools_allowlist .as_deref() .is_none_or(|a| tool_id_matches(a, id)) } /// Whether a hosted/server-side tool `id` survives the agent's own /// `disallowed_tools`/`tools` and the session clamp. Hosted tools aren't in /// `tool_config`, so they're gated by name here — and strictly (no /// compat-name mapping or unresolved-entry fallback like the function path). pub(crate) fn hosted_tool_allowed(&self, id: &str) -> bool { if tool_id_matches(&self.disallowed_tools, id) { return false; } if !self.tools.is_empty() && !tool_id_matches(&self.tools, id) { return false; } self.session_tools_allowed(id) } /// Replace the file-operation tools (read/edit/search) in the tool config /// with the given set. Used by the shell layer to swap from standard to /// hashline toolset based on `config.toml` / remote settings. /// True iff the active system prompt template for `audience` carries /// the `` block. /// /// Used by the runtime turn-end TodoGate to gate firing on sessions /// whose prompt actually references the rules the gate's reminder /// text invokes. The block has been removed from every built-in /// template, so this returns `false` unconditionally. Kept as a /// helper so the gate's call-site stays stable in case the block /// is reintroduced behind a future flag. pub fn carries_task_completion_discipline( &self, _audience: crate::prompt::context::PromptAudience, ) -> bool { false } /// True iff this agent's wire format is non-interchangeable with the /// stock harness, so a client-supplied `_meta.agentProfile` must NOT /// override it. Strict iff any of: bespoke `system_prompt` template, /// bespoke `user_message_template`, or curated toolset /// (`!inject_default_tools`). Stock `kigi*` agents leave all /// three at defaults and are non-strict. pub fn is_strict_harness(&self) -> bool { use crate::prompt::context::TemplateOverride; use crate::prompt::user_message::UserMessageTemplate; let prompt_is_custom = !matches!(self.system_prompt, TemplateOverride::None); let user_template_is_custom = !matches!(self.user_message_template, UserMessageTemplate::Default); let toolset_is_curated = !self.inject_default_tools; prompt_is_custom || user_template_is_custom || toolset_is_curated } /// Swap the definition's file tools for the equivalents in `file_tools` /// (hashline vs standard), slot by slot — never granting a slot the /// definition doesn't already have (read-only toolsets stay read-only). pub fn override_file_tools( &mut self, file_tools: Vec, ) { const FILE_TOOL_SLOTS: &[[&str; 2]] = &[ ["Kigi:read_file", "KigiHashline:hashline_read"], ["Kigi:search_replace", "KigiHashline:hashline_edit"], ["Kigi:grep", "KigiHashline:hashline_grep"], ]; for tool in self.tool_config.tools.iter_mut() { let Some(slot) = FILE_TOOL_SLOTS .iter() .find(|slot| slot.contains(&tool.id.as_str())) else { continue; }; if let Some(replacement) = file_tools.iter().find(|ft| slot.contains(&ft.id.as_str())) { *tool = replacement.clone(); } } } /// Shared defaults for built-in constructors. fn base(name: BuiltinAgentName, description: &str) -> Self { Self::builtin_defaults(name.as_ref(), description) } /// Shared defaults for out-of-tree built-in agent registrations. pub fn builtin_defaults(name: &str, description: &str) -> Self { Self { name: name.to_owned(), description: description.to_string(), plugin_name: None, prompt_mode: PromptMode::Extend, tool_config: default_kigi_toolset(), capability_mode: None, permission_mode: PermissionMode::Default, skills: vec![], agents_md: true, discover_skills: true, inherit_skills: true, inject_default_tools: true, disallowed_tools: vec![], tools: vec![], effort: None, max_turns: None, isolation: None, background: None, color: None, initial_prompt: None, mcp_servers: vec![], mcp_inheritance: McpInheritance::All, hooks: None, memory: None, allowed_subagent_types: None, session_tools_allowlist: None, session_tools_denylist: None, model: ModelOverride::Inherit, completion_requirement: None, prompt_body: None, system_prompt: TemplateOverride::None, source_path: None, user_message_template: UserMessageTemplate::Default, scope: AgentScope::BuiltIn, } } pub fn default_kigi() -> Self { Self::base( BuiltinAgentName::Kigi, "Kigi agent for software engineering tasks.", ) } /// Kigi Concise agent definition — concise output format for SFT/RL. pub fn kigi_concise() -> Self { Self { tool_config: kigi_concise_toolset(), agents_md: false, ..Self::base( BuiltinAgentName::KigiConcise, "Kigi agent with concise output format.", ) } } /// Kigi agent with plan mode tools. pub fn kigi_plan() -> Self { Self { tool_config: kigi_plan_toolset(), ..Self::base( BuiltinAgentName::KigiPlan, "Kigi agent with plan mode support.", ) } } /// Kigi + plan mode WITHOUT subagent tools. pub fn kigi_plan_no_subagents() -> Self { Self { tool_config: kigi_plan_no_subagents_toolset(), ..Self::base( BuiltinAgentName::KigiPlanNoSubagents, "Kigi agent with plan mode (no subagents).", ) } } /// Default Kigi agent with the `ask_user_question` tool. pub fn kigi_ask_user() -> Self { Self { tool_config: kigi_ask_user_toolset(), ..Self::base( BuiltinAgentName::KigiAskUser, "Kigi agent with ask-user-question tool.", ) } } pub fn codex() -> Self { Self { tool_config: codex_toolset(), system_prompt: TemplateOverride::Codex, ..Self::base(BuiltinAgentName::Codex, "Codex toolset and prompt") } } pub fn opencode() -> Self { Self { tool_config: opencode_toolset(), ..Self::base( BuiltinAgentName::Opencode, "OpenCode toolset — opencode-style tools and parameter conventions", ) } } /// General-purpose subagent definition. pub fn general_purpose() -> Self { use crate::prompt::subagent_prompts; Self { description: kigi_tool_types::GENERAL_PURPOSE_SUBAGENT .description .to_string(), prompt_body: Some(subagent_prompts::GENERAL_PURPOSE_PROMPT.to_string()), ..Self::base(BuiltinAgentName::GeneralPurpose, "") } } /// Explore subagent — fast, read-only codebase exploration. pub fn explore() -> Self { use crate::prompt::subagent_prompts; Self { description: kigi_tool_types::EXPLORE_SUBAGENT.description.to_string(), tool_config: explore_toolset(), permission_mode: PermissionMode::Plan, prompt_body: Some(subagent_prompts::EXPLORE_PROMPT.to_string()), inherit_skills: false, ..Self::base(BuiltinAgentName::Explore, "") } } /// Plan subagent — read-only architect for implementation plans. pub fn plan() -> Self { use crate::prompt::subagent_prompts; Self { description: kigi_tool_types::PLAN_SUBAGENT.description.to_string(), tool_config: plan_toolset(), permission_mode: PermissionMode::Plan, prompt_body: Some(subagent_prompts::PLAN_PROMPT.to_string()), inherit_skills: false, ..Self::base(BuiltinAgentName::Plan, "") } } /// Browser Use agent definition. pub fn browser_use() -> Self { Self { prompt_mode: PromptMode::Full, agents_md: false, prompt_body: Some( "You are a web browsing agent. You can navigate, interact with, and \ extract information from web pages. Use the available browsing tools \ to complete the user's request." .to_string(), ), ..Self::base( BuiltinAgentName::BrowserUse, "Web browsing and interaction agent.", ) } } /// Kigi Orchestrator — GBL model with full Kigi tools /// (skills, MCPs, plan mode) that delegates coding/exploration to /// subagents. /// /// Subagent overrides are applied in `handle_subagent_request`: /// general-purpose children get `implementer_toolset()` and explore /// children get `explorer_toolset()`, both with the subagent model. pub fn kigi_orchestrator() -> Self { Self { tool_config: orchestrator_toolset(), inject_default_tools: false, prompt_body: Some(ORCHESTRATOR_PROMPT_BODY.to_string()), ..Self::base( BuiltinAgentName::KigiOrchestrator, "Kigi orchestrator that delegates coding to specialized subagents", ) } } /// Deserialize an agent definition from a JSON value (e.g. from ACP `_meta.agentProfile`). /// /// Unlike `parse()` (which reads YAML frontmatter + Markdown body from a file), /// this method accepts a flat JSON object where `promptBody` is an explicit /// string field rather than the body below `---` delimiters. /// /// ```json /// { /// "name": "my-agent", /// "description": "A custom agent profile.", /// "promptMode": "extend", /// "permissionMode": "dontAsk", /// "promptBody": "You are a specialized coding assistant..." /// } /// ``` pub fn from_json(value: &serde_json::Value) -> Result { let mut def: AgentDefinition = serde_json::from_value(value.clone()) .map_err(|e| AgentBuildError::ParseError(e.to_string()))?; if let Some(body) = value.get("promptBody").and_then(|v| v.as_str()) { let trimmed = body.trim(); if !trimmed.is_empty() { def.prompt_body = Some(trimmed.to_string()); } } if !value.get("toolConfig").is_some_and(|v| v.is_object()) { def.tool_config = default_kigi_toolset(); } def.scope = AgentScope::BuiltIn; Ok(def) } /// Serialize to a JSON value suitable for `from_json` roundtrip. /// Handles `prompt_body` which is `#[serde(skip)]` on the struct. pub fn to_json_value(&self) -> serde_json::Value { let mut value = serde_json::to_value(self).expect("AgentDefinition is always serializable"); if let Some(ref body) = self.prompt_body { value["promptBody"] = serde_json::Value::String(body.clone()); } value } } #[cfg(test)] mod tests { use super::*; /// Native presets only. #[test] fn toolset_for_preset_resolves_known_names() { for name in [ "kigi", "kigi", "kigi-concise", "kigi-plan", "codex", "explore", "plan", "kigi-computer", "kigi_computer", ] { assert!( toolset_for_preset(name).is_some(), "preset `{name}` should resolve" ); } assert!(toolset_for_preset("does-not-exist").is_none()); } #[test] fn presets_select_distinct_toolsets_by_size() { let gb = toolset_for_preset("kigi").unwrap(); let plan = toolset_for_preset("plan").unwrap(); let explore = toolset_for_preset("explore").unwrap(); assert!(explore.tools.len() < plan.tools.len()); assert!(plan.tools.len() < gb.tools.len()); } fn kigi_computer_exclusive_ids() -> Vec { #[allow(unused_mut)] let mut ids: Vec = vec![ ToolConfig::from(&kigi::GetTerminalCommandOutputTool).id, ToolConfig::from(&kigi::KillTerminalCommandTool).id, ]; ids } #[test] fn kigi_computer_preset_is_curated_kigi_subset() { let gc = toolset_for_preset("kigi-computer").unwrap(); let gb = toolset_for_preset("kigi").unwrap(); let gb_ids: std::collections::HashSet<&str> = gb.tools.iter().map(|t| t.id.as_str()).collect(); let exclusive_ids = kigi_computer_exclusive_ids(); assert!(!gc.tools.is_empty()); for t in &gc.tools { if exclusive_ids.contains(&t.id) { continue; } assert!( gb_ids.contains(t.id.as_str()), "kigi-computer tool `{}` must also ship in the kigi preset", t.id ); } assert!( gc.tools.len() < gb.tools.len(), "kigi-computer should be a curated subset of kigi" ); } #[test] fn kigi_computer_uses_subagent_free_background_task_tools() { let gc = toolset_for_preset("kigi-computer").unwrap(); let ids: std::collections::HashSet<&str> = gc.tools.iter().map(|t| t.id.as_str()).collect(); assert!( ids.contains( ToolConfig::from(&kigi::GetTerminalCommandOutputTool) .id .as_str() ) ); assert!(ids.contains(ToolConfig::from(&kigi::KillTerminalCommandTool).id.as_str())); assert!(!ids.contains(ToolConfig::from(&kigi::TaskOutputTool).id.as_str())); assert!(!ids.contains(ToolConfig::from(&kigi::KillTaskTool).id.as_str())); assert!(!ids.contains(ToolConfig::from(&kigi::TaskTool).id.as_str())); for t in &gc.tools { if t.id == ToolConfig::from(&kigi::GetTerminalCommandOutputTool).id || t.id == ToolConfig::from(&kigi::KillTerminalCommandTool).id { assert!(t.name_override.is_none(), "tool `{}` must not rename", t.id); } } } /// The kigi-computer preset must ship a full-file write tool (legacy /// `write_file` parity) — the same OpenCode `write` tool the kigi /// preset uses. Guards against `search_replace` being the only /// file-mutation path, which has no single-tool full-rewrite when the /// empty-old_string overwrite guard is enabled. #[test] fn kigi_computer_preset_includes_write_tool() { let gc = toolset_for_preset("kigi-computer").unwrap(); let write_id = ToolConfig::from(&opencode::OpenCodeWriteTool).id; assert!( gc.tools.iter().any(|t| t.id == write_id), "kigi-computer preset must include the `{write_id}` tool" ); } #[test] fn kigi_computer_preset_excludes_plan_and_lsp() { let gc = toolset_for_preset("kigi-computer").unwrap(); let gc_ids: std::collections::HashSet<&str> = gc.tools.iter().map(|t| t.id.as_str()).collect(); for excluded in [ ToolConfig::from(&kigi::LspTool).id, ToolConfig::from(&kigi::EnterPlanModeTool).id, ToolConfig::from(&kigi::ExitPlanModeTool).id, ] { assert!( !gc_ids.contains(excluded.as_str()), "kigi-computer preset must not advertise `{excluded}`" ); } let full = workspace_kigi_toolset(); let full_ids: std::collections::HashSet<&str> = full.tools.iter().map(|t| t.id.as_str()).collect(); for present in [ ToolConfig::from(&kigi::LspTool).id, ToolConfig::from(&kigi::EnterPlanModeTool).id, ToolConfig::from(&kigi::ExitPlanModeTool).id, ] { assert!( full_ids.contains(present.as_str()), "workspace_kigi_toolset must ship `{present}`" ); } } /// Exhaustive match → adding a new `BuiltinAgentName` won't compile /// until classified. fn expected_strict_harness(name: BuiltinAgentName) -> bool { match name { BuiltinAgentName::Codex | BuiltinAgentName::KigiOrchestrator => true, BuiltinAgentName::Kigi | BuiltinAgentName::KigiConcise | BuiltinAgentName::KigiPlan | BuiltinAgentName::KigiPlanNoSubagents | BuiltinAgentName::KigiAskUser | BuiltinAgentName::GeneralPurpose | BuiltinAgentName::Explore | BuiltinAgentName::Plan | BuiltinAgentName::Opencode | BuiltinAgentName::BrowserUse => false, } } /// Invariant: structural `is_strict_harness()` must match the /// hand-classified expectation for every built-in variant. #[test] fn is_strict_harness_matches_structural_classification_for_all_builtins() { use strum::IntoEnumIterator; for variant in BuiltinAgentName::iter() { let structural = variant.definition().is_strict_harness(); let expected = expected_strict_harness(variant); assert_eq!( structural, expected, "BuiltinAgentName::{variant:?}: structural={structural} but \ expected={expected}. Update `expected_strict_harness` if the \ change is intentional.", ); } } #[test] fn is_strict_harness_agent_type_classifies_by_name() { for strict in ["codex", "kigi-orchestrator"] { assert!( is_strict_harness_agent_type(strict), "{strict} should be strict" ); } for non_strict in [ "kigi", "kigi-plan", "kigi-concise", "kigi-ask-user", "opencode", "browser-use", "custom-user-agent", "", "kigi-totally-made-up", ] { assert!( !is_strict_harness_agent_type(non_strict), "{non_strict} should be non-strict", ); } } #[test] fn test_parse_valid_full_definition() { let content = r#"--- name: test-agent description: A test agent promptMode: full tools: - read_file - grep permissionMode: plan agentsMd: false --- You are a test agent. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.name, "test-agent"); assert_eq!(def.description, "A test agent"); assert_eq!(def.prompt_mode, PromptMode::Full); assert_eq!(def.permission_mode, PermissionMode::Plan); assert!(!def.agents_md); assert_eq!(def.prompt_body.as_deref(), Some("You are a test agent.")); assert_eq!( def.tools, vec!["read_file".to_string(), "grep".to_string()], "tools allowlist must be parsed from YAML frontmatter" ); } #[test] fn test_parse_tools_and_disallowed_together() { let content = r#"--- name: mixed-tools description: Both tools and disallowedTools tools: - read_file - grep - search_replace - task disallowedTools: - search_replace --- Mixed agent. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.tools.len(), 4, "tools allowlist should have 4 entries"); assert_eq!( def.disallowed_tools, vec!["search_replace".to_string()], "disallowedTools should have 1 entry" ); } #[test] fn test_parse_tools_with_agent_parens() { let content = r#"--- name: coordinator description: test tools: Agent(worker, researcher), Read, Bash --- Agent. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.tools, vec!["Agent(worker, researcher)", "Read", "Bash"], "Agent(a, b) must be kept as a single token" ); } #[test] fn test_parse_tools_comma_separated() { let content = r#"--- name: comma-tools description: Comma-separated tools tools: read_file, grep, list_dir disallowedTools: search_replace, write --- Agent. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.tools, vec!["read_file", "grep", "list_dir"], "comma-separated tools must parse correctly" ); assert_eq!( def.disallowed_tools, vec!["search_replace", "write"], "comma-separated disallowedTools must parse correctly" ); } #[test] fn test_parse_tools_case_insensitive_agent() { let content = r#"--- name: ci-test description: test tools: agent(worker, researcher), Read --- Agent. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.tools, vec!["agent(worker, researcher)", "Read"], "lowercase agent(a, b) must be kept as a single token" ); } #[test] fn test_parse_max_turns_zero_rejected() { let content = "---\nname: test\ndescription: Test\nmaxTurns: 0\n---\n"; let result = AgentDefinition::parse(content); assert!( result.is_err(), "maxTurns: 0 should be rejected at parse time" ); } #[test] fn test_parse_model_empty_is_inherit() { let content = "---\nname: test\ndescription: Test\nmodel: \"\"\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.model, ModelOverride::Inherit); } #[test] fn test_parse_model_null_is_inherit() { let content = "---\nname: test\ndescription: Test\nmodel: ~\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.model, ModelOverride::Inherit); } #[test] fn test_parse_minimal_defaults_none_fields() { let content = "---\nname: minimal\ndescription: Test\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert!(def.effort.is_none()); assert!(def.max_turns.is_none()); assert!(def.isolation.is_none()); assert!(def.background.is_none()); assert!(def.color.is_none()); assert!(def.initial_prompt.is_none()); assert!(def.memory.is_none()); assert!(def.hooks.is_none()); } #[test] fn test_model_override_display_shows_id() { assert_eq!(ModelOverride::Inherit.to_string(), "inherit"); assert_eq!( ModelOverride::Override("kigi-3-fast".to_string()).to_string(), "kigi-3-fast" ); } #[test] fn test_parse_new_fields() { let content = r#"--- name: full-fields description: All new fields effort: high maxTurns: 10 isolation: worktree background: true color: blue initialPrompt: "hello world" model: kigi-3 --- Agent body. "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.effort, Some(Effort::High)); assert_eq!(def.max_turns, Some(10)); assert_eq!(def.isolation, Some(IsolationMode::Worktree)); assert_eq!(def.background, Some(true)); assert_eq!(def.color, Some(AgentColor::Blue)); assert_eq!(def.initial_prompt.as_deref(), Some("hello world")); assert_eq!(def.model, ModelOverride::Override("kigi-3".to_string())); } #[test] fn test_parse_minimal_definition() { let content = r#"--- name: minimal description: Minimal agent --- "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.name, "minimal"); assert_eq!(def.description, "Minimal agent"); assert_eq!(def.prompt_mode, PromptMode::Extend); assert!(def.agents_md); assert!(def.prompt_body.is_none()); } #[test] fn mcp_server_ref_parse_and_reject() { let v: McpServerRef = serde_json::from_value(serde_json::json!("slack")).unwrap(); assert_eq!(v, McpServerRef::Named("slack".to_string())); let v: McpServerRef = serde_json::from_value(serde_json::json!({ "s" : { "type" : "stdio" } })).unwrap(); assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s")); let v: McpServerRef = serde_json::from_value(serde_json::json!({ "name" : "s", "type" : "stdio" })).unwrap(); assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s")); assert!( serde_json::from_value::(serde_json::json!({ "type" : "stdio" })) .is_err() ); assert!(serde_json::from_value::(serde_json::json!(42)).is_err()); assert!( serde_json::from_value::(serde_json::json!({ "s" : "bad" })).is_err() ); } #[test] fn memory_scope_resolve_dir() { let cwd = std::path::Path::new("/project"); let user = MemoryScope::User.resolve_dir("a", cwd); assert!(user.path.ends_with("agent-memory/a")); assert!(!user.is_project_scoped); let proj = MemoryScope::Project.resolve_dir("a", cwd); assert_eq!( proj.path, std::path::PathBuf::from("/project/.kigi/agent-memory/a") ); assert!(proj.is_project_scoped); let local = MemoryScope::Local.resolve_dir("a", cwd); assert_eq!( local.path, std::path::PathBuf::from("/project/.kigi/agent-memory-local/a") ); assert!(local.is_project_scoped); } #[test] fn all_new_enum_variants_parse() { for effort in Effort::VALID_VALUES { let c = format!("---\nname: t\ndescription: t\neffort: {effort}\n---\n"); assert!( AgentDefinition::parse(&c).unwrap().effort.is_some(), "effort: {effort}" ); } for iso in IsolationMode::VALID_VALUES { let c = format!("---\nname: t\ndescription: t\nisolation: {iso}\n---\n"); assert!( AgentDefinition::parse(&c).unwrap().isolation.is_some(), "isolation: {iso}" ); } for color in AgentColor::VALID_VALUES { let c = format!("---\nname: t\ndescription: t\ncolor: {color}\n---\n"); assert!( AgentDefinition::parse(&c).unwrap().color.is_some(), "color: {color}" ); } for memory in MemoryScope::VALID_VALUES { let c = format!("---\nname: t\ndescription: t\nmemory: {memory}\n---\n"); assert!( AgentDefinition::parse(&c).unwrap().memory.is_some(), "memory: {memory}" ); } } #[test] fn test_parse_missing_name() { let content = r#"--- description: No name --- "#; let result = AgentDefinition::parse(content); assert!(result.is_err()); match result.unwrap_err() { AgentBuildError::ParseError(msg) => { assert!(msg.contains("name"), "Error should mention 'name': {}", msg); } e => panic!("Expected ParseError, got: {:?}", e), } } #[test] fn test_parse_missing_delimiters() { let content = "Just some text without frontmatter"; let result = AgentDefinition::parse(content); assert!(result.is_err()); match result.unwrap_err() { AgentBuildError::ParseError(msg) => { assert!(msg.contains("delimiter") || msg.contains("frontmatter")); } e => panic!("Expected ParseError, got: {:?}", e), } } #[test] fn test_parse_unknown_fields_ignored() { let content = r#"--- name: test description: Test hooks: PreToolUse: - matcher: Bash hooks: - type: command command: echo hi memory: user unknownField: value --- "#; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.name, "test"); } #[test] fn test_parse_completion_requirement() { let content = r#"--- name: completion-test description: Test completion requirement parsing completionRequirement: tool: my_agent__complete_task reminder: You must call complete_task recovery: maxRetries: 5 baseDelayMs: 5000 maxDelayMs: 60000 --- "#; let def = AgentDefinition::parse(content).unwrap(); let req = def.completion_requirement.unwrap(); assert_eq!(req.tool, "my_agent__complete_task"); assert_eq!(req.reminder, "You must call complete_task"); let recovery = req.recovery.unwrap(); assert_eq!(recovery.max_retries, 5); assert_eq!(recovery.base_delay_ms, 5000); assert_eq!(recovery.max_delay_ms, 60000); } #[test] fn test_builtin_browser_use() { let def = AgentDefinition::browser_use(); assert_eq!(def.name, "browser-use"); assert_eq!(def.prompt_mode, PromptMode::Full); assert!(!def.agents_md); } #[test] fn test_completion_requirement_round_trips() { let content = r#"--- name: roundtrip description: Test round-trip completionRequirement: tool: my__complete reminder: Please complete recovery: maxRetries: 3 baseDelayMs: 1000 maxDelayMs: 10000 --- "#; let def = AgentDefinition::parse(content).unwrap(); let req = def.completion_requirement.as_ref().unwrap(); assert_eq!(req.tool, "my__complete"); assert_eq!(req.reminder, "Please complete"); let rec = req.recovery.as_ref().unwrap(); assert_eq!(rec.max_retries, 3); assert_eq!(rec.base_delay_ms, 1000); assert_eq!(rec.max_delay_ms, 10000); } #[test] fn test_default_tool_config_has_kigi_tools() { let content = r#"--- name: default-tools description: Test default tool config --- "#; let def = AgentDefinition::parse(content).unwrap(); assert!( !def.tool_config.tools.is_empty(), "default tool_config should have kigi tools" ); } #[test] fn test_permission_mode_round_trips() { for v in PermissionMode::VALID_VALUES { let content = format!("---\nname: test\ndescription: Test\npermissionMode: {v}\n---\n"); AgentDefinition::parse(&content) .unwrap_or_else(|e| panic!("PermissionMode '{v}' failed parse: {e}")); } } #[test] fn test_prompt_mode_round_trips() { for (yaml_val, expected) in [("extend", PromptMode::Extend), ("full", PromptMode::Full)] { let content = format!("---\nname: test\ndescription: Test\npromptMode: {yaml_val}\n---\n"); let def = AgentDefinition::parse(&content).unwrap(); assert_eq!(def.prompt_mode, expected, "Failed for: {yaml_val}"); } } #[test] fn test_from_file_sets_scope_and_path() { let tmp = tempfile::tempdir().unwrap(); let file_path = tmp.path().join("test-agent.md"); std::fs::write( &file_path, "---\nname: file-test\ndescription: From file\n---\n", ) .unwrap(); let def = AgentDefinition::from_file(&file_path).unwrap(); assert_eq!(def.name, "file-test"); assert_eq!(def.source_path, Some(file_path)); } #[test] fn test_scope_from_path_detects_bundled_agents() { let tmp = tempfile::tempdir().unwrap(); let bundled = tmp .path() .join("nested") .join(".kigi") .join("bundled") .join("agents") .join("bundled-agent.md"); std::fs::create_dir_all(bundled.parent().unwrap()).unwrap(); std::fs::write( &bundled, "---\nname: bundled-agent\ndescription: Bundled agent\n---\n", ) .unwrap(); let def = AgentDefinition::from_file(&bundled).unwrap(); assert_eq!(def.scope, AgentScope::Bundled); assert_eq!(AgentScope::Bundled.label(), "bundled"); assert_eq!(AgentScope::BuiltIn.label(), "built-in"); } #[test] fn test_from_json_minimal() { let json = serde_json::json!( { "name" : "acp-agent", "description" : "An agent from ACP" } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!(def.name, "acp-agent"); assert_eq!(def.description, "An agent from ACP"); assert_eq!(def.prompt_mode, PromptMode::Extend); assert!(def.agents_md); assert!(def.prompt_body.is_none()); assert_eq!(def.scope, AgentScope::BuiltIn); } #[test] fn test_from_json_has_default_toolset_with_task_tool() { let json = serde_json::json!( { "name" : "kigi", "description" : "Multi-surface coding agent.", "promptMode" : "extend", "permissionMode" : "dontAsk", "agentsMd" : true, "promptBody" : "You are a coding assistant." } ); let def = AgentDefinition::from_json(&json).unwrap(); let task_tool_id = "Kigi:task"; assert!( def.tool_config.tools.iter().any(|tc| tc.id == task_tool_id), "from_json() without toolConfig should include TaskTool in default toolset, \ got tool IDs: {:?}", def.tool_config .tools .iter() .map(|tc| &tc.id) .collect::>() ); } #[test] fn test_from_json_with_prompt_body() { let json = serde_json::json!( { "name" : "custom-agent", "description" : "Agent with prompt body", "promptBody" : "You are a specialized coding assistant.\n\nFocus on Rust." } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!(def.name, "custom-agent"); assert_eq!( def.prompt_body.as_deref(), Some("You are a specialized coding assistant.\n\nFocus on Rust.") ); } #[test] fn test_from_json_with_permission_mode() { let json = serde_json::json!( { "name" : "auto-accept-agent", "description" : "Agent with dontAsk permission mode", "permissionMode" : "dontAsk", "promptBody" : "## Auto-accept Mode" } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!(def.permission_mode, PermissionMode::DontAsk); assert_eq!(def.prompt_body.as_deref(), Some("## Auto-accept Mode")); } #[test] fn test_from_json_empty_prompt_body_is_none() { let json = serde_json::json!( { "name" : "test", "description" : "Test", "promptBody" : " " } ); let def = AgentDefinition::from_json(&json).unwrap(); assert!( def.prompt_body.is_none(), "Whitespace-only promptBody should be None" ); } #[test] fn test_from_json_missing_required_fields() { let json = serde_json::json!({ "description" : "Missing name" }); let result = AgentDefinition::from_json(&json); assert!(result.is_err()); } #[test] fn test_from_json_ignores_unknown_fields() { let json = serde_json::json!( { "name" : "test", "description" : "Test", "unknownField" : "value", "futureFeature" : true } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!(def.name, "test"); } #[test] fn to_json_value_roundtrips_through_from_json() { let mut original = AgentDefinition::parse( "---\nname: test-agent\ndescription: A test\npermissionMode: dontAsk\n---\nYou are a helper.", ) .unwrap(); original.tools = vec!["read_file".to_string(), "grep".to_string()]; original.disallowed_tools = vec!["web_search".to_string()]; let json = original.to_json_value(); let recovered = AgentDefinition::from_json(&json).unwrap(); assert_eq!(recovered.name, "test-agent"); assert_eq!(recovered.description, "A test"); assert_eq!(recovered.prompt_body.as_deref(), Some("You are a helper.")); assert_eq!(recovered.permission_mode, PermissionMode::DontAsk); assert_eq!(recovered.tools, vec!["read_file", "grep"]); assert_eq!(recovered.disallowed_tools, vec!["web_search"]); } #[test] fn test_model_override_default_is_inherit() { assert_eq!(ModelOverride::default(), ModelOverride::Inherit); } #[test] fn test_model_override_serde_inherit() { let yaml = "\"inherit\""; let m: ModelOverride = serde_yaml::from_str(yaml).unwrap(); assert_eq!(m, ModelOverride::Inherit); } #[test] fn test_model_override_serde_inherit_case_insensitive() { let yaml = "\"Inherit\""; let m: ModelOverride = serde_yaml::from_str(yaml).unwrap(); assert_eq!(m, ModelOverride::Inherit); } #[test] fn test_model_override_serde_explicit_model_id() { let yaml = "\"kigi-3-fast\""; let m: ModelOverride = serde_yaml::from_str(yaml).unwrap(); assert_eq!(m, ModelOverride::Override("kigi-3-fast".to_string())); } #[test] fn test_model_override_serialize_inherit() { let m = ModelOverride::Inherit; let s = serde_json::to_string(&m).unwrap(); assert_eq!(s, "\"inherit\""); } #[test] fn test_model_override_serialize_override() { let m = ModelOverride::Override("kigi-3-fast".to_string()); let s = serde_json::to_string(&m).unwrap(); assert_eq!(s, "\"kigi-3-fast\""); } #[test] fn test_model_override_in_frontmatter() { let content = "---\nname: test\ndescription: Test\nmodel: kigi-3-fast\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.model, ModelOverride::Override("kigi-3-fast".to_string()) ); } #[test] fn test_model_override_in_frontmatter_inherit() { let content = "---\nname: test\ndescription: Test\nmodel: inherit\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.model, ModelOverride::Inherit); } #[test] fn test_model_override_omitted_defaults_to_inherit() { let content = "---\nname: test\ndescription: Test\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!(def.model, ModelOverride::Inherit); } #[test] fn test_model_override_in_json() { let json = serde_json::json!( { "name" : "test", "description" : "Test", "model" : "kigi-code-fast-1" } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!( def.model, ModelOverride::Override("kigi-code-fast-1".to_string()) ); } #[test] fn test_builtin_agent_name_strum_round_trip() { use std::str::FromStr; for (s, expected) in [ ("kigi", BuiltinAgentName::Kigi), ("kigi-concise", BuiltinAgentName::KigiConcise), ("kigi-ask-user", BuiltinAgentName::KigiAskUser), ("codex", BuiltinAgentName::Codex), ("opencode", BuiltinAgentName::Opencode), ("general-purpose", BuiltinAgentName::GeneralPurpose), ("explore", BuiltinAgentName::Explore), ("plan", BuiltinAgentName::Plan), ("browser-use", BuiltinAgentName::BrowserUse), ] { let parsed = BuiltinAgentName::from_str(s).unwrap(); assert_eq!(parsed, expected, "from_str failed for: {s}"); assert_eq!(parsed.as_ref(), s, "as_ref failed for: {s}"); } } #[test] fn test_builtin_agent_name_unknown_returns_err() { use std::str::FromStr; assert!(BuiltinAgentName::from_str("nonexistent").is_err()); assert!(BuiltinAgentName::from_str("not-a-builtin-agent").is_err()); } /// Legacy pre-rebrand agent types (as persisted in old session files) /// must map onto the current `kigi*` names; everything else passes /// through unchanged. #[test] fn test_canonical_agent_type_maps_legacy_names() { use std::str::FromStr; let legacy = |suffix: &str| format!("{LEGACY_AGENT_TYPE_PREFIX}{suffix}"); for (suffix, expected) in [ ("", "kigi"), ("-concise", "kigi-concise"), ("-plan", "kigi-plan"), ("-plan-no-subagents", "kigi-plan-no-subagents"), ("-ask-user", "kigi-ask-user"), ("-orchestrator", "kigi-orchestrator"), ] { let name = legacy(suffix); assert_eq!(canonical_agent_type(&name), expected, "for {name}"); // Every mapped name resolves to a real builtin. assert!( BuiltinAgentName::from_str(canonical_agent_type(&name).as_ref()).is_ok(), "legacy {name} must resolve to a builtin" ); } // Pass-through for current and unrelated names. assert_eq!(canonical_agent_type("kigi-plan"), "kigi-plan"); assert_eq!(canonical_agent_type("general-purpose"), "general-purpose"); // A prefix match without a `-` separator is NOT a legacy alias. let not_alias = legacy("ish"); assert_eq!(canonical_agent_type(¬_alias), not_alias.as_str()); } /// The legacy default agent type resolves through discovery `by_name`, /// which is the boundary session-restore and model `agent_type` strings /// pass through. #[test] fn test_by_name_resolves_legacy_agent_type() { let legacy_plan = format!("{LEGACY_AGENT_TYPE_PREFIX}-plan"); let def = crate::discovery::by_name(&legacy_plan).expect("legacy agent type resolves"); assert_eq!(def.name, "kigi-plan"); assert!( is_strict_harness_agent_type(&format!("{LEGACY_AGENT_TYPE_PREFIX}-concise")) == is_strict_harness_agent_type("kigi-concise") ); } #[test] fn test_builtin_agent_name_definition_names_match() { use strum::IntoEnumIterator; for variant in BuiltinAgentName::iter() { let def = variant.definition(); assert_eq!( def.name, variant.as_ref(), "definition().name doesn't match as_ref() for {:?}", variant ); } } #[test] fn test_builtin_agent_name_subagent_variants() { let variants = BuiltinAgentName::subagent_variants(); assert_eq!(variants.len(), 3); assert!(variants.contains(&BuiltinAgentName::GeneralPurpose)); assert!(variants.contains(&BuiltinAgentName::Explore)); assert!(variants.contains(&BuiltinAgentName::Plan)); } #[test] fn test_all_builtins_have_inherit_model() { use strum::IntoEnumIterator; for variant in BuiltinAgentName::iter() { let def = variant.definition(); assert_eq!( def.model, ModelOverride::Inherit, "Built-in {:?} should default to Inherit", variant ); } } #[test] fn mcp_inheritance_default_when_omitted() { let def = AgentDefinition::parse("---\nname: t\ndescription: t\n---\n").unwrap(); assert_eq!(def.mcp_inheritance, McpInheritance::All); } #[test] fn mcp_inheritance_all_parses() { let def = AgentDefinition::parse("---\nname: t\ndescription: t\nmcpInheritance: all\n---\n") .unwrap(); assert_eq!(def.mcp_inheritance, McpInheritance::All); } #[test] fn mcp_inheritance_none_parses() { let def = AgentDefinition::parse("---\nname: t\ndescription: t\nmcpInheritance: none\n---\n") .unwrap(); assert_eq!(def.mcp_inheritance, McpInheritance::None); } #[test] fn mcp_inheritance_named_parses() { let content = "---\nname: t\ndescription: t\nmcpInheritance:\n named:\n - slack\n - github\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.mcp_inheritance, McpInheritance::Named(vec!["slack".into(), "github".into()]) ); } #[test] fn mcp_inheritance_except_parses() { let content = "---\nname: t\ndescription: t\nmcpInheritance:\n except:\n - internal\n---\n"; let def = AgentDefinition::parse(content).unwrap(); assert_eq!( def.mcp_inheritance, McpInheritance::Except(vec!["internal".into()]) ); } #[test] fn mcp_inheritance_round_trips_via_json() { let json = serde_json::json!( { "name" : "t", "description" : "t", "mcpInheritance" : { "named" : ["a", "b"] } } ); let def = AgentDefinition::from_json(&json).unwrap(); assert_eq!( def.mcp_inheritance, McpInheritance::Named(vec!["a".into(), "b".into()]) ); let serialized = def.to_json_value(); let recovered = AgentDefinition::from_json(&serialized).unwrap(); assert_eq!(recovered.mcp_inheritance, def.mcp_inheritance); } fn def_with_template(tpl: crate::prompt::context::TemplateOverride) -> AgentDefinition { let mut def = AgentDefinition::default_kigi(); def.system_prompt = tpl; def } #[test] fn carries_discipline_false_for_every_template_and_audience() { for tpl in [ crate::prompt::context::TemplateOverride::None, crate::prompt::context::TemplateOverride::Codex, crate::prompt::context::TemplateOverride::Custom("fake".to_string()), ] { let def = def_with_template(tpl.clone()); for audience in [ crate::prompt::context::PromptAudience::Primary, crate::prompt::context::PromptAudience::Subagent, ] { assert!( !def.carries_task_completion_discipline(audience), "discipline block was removed; helper must return false \ (template: {tpl:?}, audience: {audience:?})" ); } } } }