Files
Kigi-CLI/crates/codegen/kigi-agent/src/config.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
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.
2026-07-18 02:48:46 -04:00

2605 lines
98 KiB
Rust

//! 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<Mutex<HashMap<String, (ToolsetPresetBuilder, PresetVisibility)>>> =
OnceLock::new();
fn toolset_preset_registry()
-> &'static Mutex<HashMap<String, (ToolsetPresetBuilder, PresetVisibility)>> {
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<ToolServerConfig> {
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<String> {
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<String> {
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<ToolServerConfig> {
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<kigi_tools::registry::types::ToolConfig>,
) -> ToolServerConfig {
let mut tools: Vec<kigi_tools::registry::types::ToolConfig> = 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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<regex::Regex> =
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<regex::Regex> =
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<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrVec;
impl<'de> de::Visitor<'de> for StringOrVec {
type Value = Vec<String>;
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<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
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<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut v = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
v.push(item);
}
Ok(v)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
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<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<u32>::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 (`<prefix>`, `<prefix>-plan`, `<prefix>-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: `<prefix>` → `kigi`, `<prefix>-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<String>,
#[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<kigi_tool_types::SubagentCapabilityMode>,
#[serde(default)]
pub permission_mode: PermissionMode,
#[serde(default)]
pub skills: Vec<String>,
/// 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<String>,
/// Tool denylist. `Agent(type)` entries strip spawn permissions.
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub disallowed_tools: Vec<String>,
#[serde(default)]
pub effort: Option<Effort>,
#[serde(default, deserialize_with = "deserialize_nonzero_u32")]
pub max_turns: Option<u32>,
#[serde(default)]
pub isolation: Option<IsolationMode>,
#[serde(default)]
pub background: Option<bool>,
#[serde(default)]
pub color: Option<AgentColor>,
#[serde(default)]
pub initial_prompt: Option<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerRef>,
#[serde(default)]
pub mcp_inheritance: McpInheritance,
#[serde(default, deserialize_with = "deserialize_hooks_map")]
pub hooks: Option<HooksConfig>,
#[serde(default)]
pub memory: Option<MemoryScope>,
#[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<CompletionRequirement>,
/// 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<Vec<String>>,
/// 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<Vec<String>>,
#[serde(skip)]
pub session_tools_denylist: Option<Vec<String>>,
#[serde(skip)]
pub prompt_body: Option<String>,
#[serde(skip)]
pub system_prompt: TemplateOverride,
/// First-user-message template selector. `Default` (the default) lets
/// the shell layer build the legacy `<user_info>` + `<git_status>`
/// 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<PathBuf>,
/// 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<RecoveryPolicy>,
}
/// 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<ToolRetryConfig>,
}
/// 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<String>),
Except(Vec<String>),
}
impl<'de> Deserialize<'de> for McpInheritance {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
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<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let key: String = map
.next_key()?
.ok_or_else(|| de::Error::invalid_length(0, &"a single-key map"))?;
let value: Vec<String> = map.next_value()?;
if map.next_key::<de::IgnoredAny>()?.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() == <PermissionMode as strum::EnumCount>::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() == <Effort as strum::EnumCount>::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() == <IsolationMode as strum::EnumCount>::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() == <AgentColor as strum::EnumCount>::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/<name>/`
User,
/// `<project>/.kigi/agent-memory/<name>/`
Project,
/// `<project>/.kigi/agent-memory-local/<name>/`
Local,
}
impl MemoryScope {
pub const VALID_VALUES: &[&str] = &["user", "project", "local"];
}
const _: () = assert!(MemoryScope::VALID_VALUES.len() == <MemoryScope as strum::EnumCount>::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<String, serde_json::Value>);
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<Option<HooksConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<serde_json::Value>::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<D>(deserializer: D) -> Result<Self, D::Error>
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String>,
}
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<Path>) -> Result<Self, AgentBuildError> {
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<Path>) -> Result<Self, AgentBuildError> {
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<Self, AgentBuildError> {
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 `<task_completion_discipline>` 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<kigi_tools::registry::types::ToolConfig>,
) {
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<Self, AgentBuildError> {
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<String> {
#[allow(unused_mut)]
let mut ids: Vec<String> = 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::<McpServerRef>(serde_json::json!({ "type" :
"stdio" }))
.is_err()
);
assert!(serde_json::from_value::<McpServerRef>(serde_json::json!(42)).is_err());
assert!(
serde_json::from_value::<McpServerRef>(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::<Vec<_>>()
);
}
#[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(&not_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:?})"
);
}
}
}
}