feat(agent-substrate/wrapper): kei-agent-runtime prepare — orchestrator ergonomics

Single-command "prepare spawn" that emits everything orchestrator needs
to invoke the Agent tool: composed prompt, subagent_type (from role's
new claude-subagent-type field), isolation mode, verify command,
ledger row.

Before this: orchestrator ran compose + read prompt + manually
constructed Agent tool call + manually built verify command. 4 steps.

After: `kei-agent-runtime prepare <task.toml> --format=human` outputs
a single copy-paste-ready block. Orchestrator pastes into Agent tool
and records the verify command for return.

Files:
- src/prepare.rs (170 LOC) — prepare() returns AgentInvocation struct
  (agent_id, prompt, subagent_type, isolation, description,
  verify_command, ledger_row)
- src/main.rs (+39 LOC) — Prepare subcommand with --format=human|json|toml
- src/lib.rs (+2 LOC — pub mod prepare)
- _roles/*.toml (5 files) — new optional claude-subagent-type field:
  - edit-local / edit-shared → "code-implementer"
  - read-only → "critic" (default; "architect" override possible)
  - explorer → "Explore"
  - git-ops → "NOT-SPAWNABLE" (refused by prepare with RULE 0.13)
- tests/prepare_smoke.rs (3 tests) — happy path, unknown role, non-spawnable refusal
- docs/AGENT-SUBSTRATE-SCHEMA.md (+ ## Orchestrator ergonomics section)

Tests: 40/40 (was 37, +3 prepare_smoke). Same path exercised in tempfile
fixtures that the real CLI would hit end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 03:25:14 +08:00
parent abf2bcb30d
commit d72ae51f16
10 changed files with 394 additions and 1 deletions

View file

@ -7,6 +7,7 @@
//! - `verifies` — 8 on-return verify capabilities
//! - `compose` — task.toml + role + capabilities → prompt.md
//! - `spawn` — prepare tasks/<agent-id>/prompt.md + ledger row
//! - `prepare` — orchestrator-facing `AgentInvocation` bundle (ergonomics)
//! - `verify` — run all verify capabilities against agent's return
//! - `simulated_merge` — orchestrator-side worktree → apply diff → verify
//!
@ -15,6 +16,7 @@
pub mod capability;
pub mod compose;
pub mod gates;
pub mod prepare;
pub mod registry;
pub mod simulated_merge;
pub mod spawn;

View file

@ -2,7 +2,7 @@
use clap::{Parser, Subcommand};
use kei_agent_runtime::capability::RunMode;
use kei_agent_runtime::{compose, spawn, verify};
use kei_agent_runtime::{compose, prepare, spawn, verify};
use std::path::PathBuf;
use std::process::ExitCode;
@ -51,6 +51,16 @@ enum Cmd {
#[arg(long)]
kit_root: Option<PathBuf>,
},
/// Assemble everything orchestrator needs to invoke Agent tool.
/// Does NOT write tasks/ on disk — inspection helper.
Prepare {
task: PathBuf,
#[arg(long)]
kit_root: Option<PathBuf>,
/// Output format: human (default) | json | toml
#[arg(long, default_value = "human")]
format: String,
},
}
fn main() -> ExitCode {
@ -62,6 +72,35 @@ fn main() -> ExitCode {
run_verify(task, worktree, kit_root, main_repo, mode)
}
Cmd::Run { task, worktree, kit_root } => run_run(task, worktree, kit_root),
Cmd::Prepare { task, kit_root, format } => run_prepare(task, kit_root, format),
}
}
fn run_prepare(task_path: PathBuf, kit_root: Option<PathBuf>, format: String) -> ExitCode {
let kit = kit_root_or_cwd(kit_root);
let task = match spawn::load_task(&task_path) {
Ok(t) => t,
Err(e) => return err("load task", e),
};
let inv = match prepare::prepare(&task, &kit) {
Ok(i) => i,
Err(e) => return err("prepare", e),
};
let rendered = match format.as_str() {
"human" => Ok(prepare::render_human(&inv)),
"json" => prepare::render_json(&inv),
"toml" => prepare::render_toml(&inv),
other => {
eprintln!("unknown format '{other}' (expected human|json|toml)");
return ExitCode::from(2);
}
};
match rendered {
Ok(s) => {
print!("{s}");
ExitCode::SUCCESS
}
Err(e) => err("render", e),
}
}

View file

@ -0,0 +1,170 @@
//! Orchestrator-facing wrapper: task.toml → everything needed to invoke
//! Claude Code's `Agent` tool in a single copy-paste-ready bundle.
//!
//! Per RULE 0.13, the orchestrator (main session) owns branch creation,
//! `isolation: "worktree"` selection, and the actual Agent-tool call. This
//! module only assembles the arguments — no git, no spawn, no shell.
//!
//! Wire: `prepare()` = `compose_prompt()` + role lookup + role→subagent_type
//! resolution. Deliberately does NOT create `tasks/<id>/` on disk (that is
//! `spawn::prepare_agent`'s job) so orchestrator can inspect before
//! committing. The "ledger row" field is a pretty-printed string, not a DB
//! write — ledger persistence is the orchestrator's step.
use crate::capability::TaskSpec;
use crate::compose::compose_prompt;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Everything the orchestrator needs to hand the Claude `Agent` tool.
#[derive(Debug, Clone, Serialize)]
pub struct AgentInvocation {
pub agent_id: String,
pub role: String,
pub prompt: String,
pub subagent_type: String,
pub isolation: Option<String>,
pub description: String,
pub verify_command: String,
pub ledger_row: String,
}
/// Assemble an `AgentInvocation` from a parsed task.toml.
///
/// Errors if the role is unknown or non-spawnable (points at RULE 0.13).
pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result<AgentInvocation> {
if task.task.role.is_empty() {
return Err(anyhow!("task.role is empty"));
}
if task.task.agent_id.is_empty() {
return Err(anyhow!(
"task.agent-id is empty — orchestrator must allocate via kei-ledger"
));
}
let role_file = load_role_file(kit_root, &task.task.role)?;
if !role_file.role.spawnable {
return Err(anyhow!(
"role '{}' is NOT spawnable (per RULE 0.13 git-ops is \
orchestrator-only) refusing to prepare Agent tool invocation",
task.task.role
));
}
let prompt = compose_prompt(task, kit_root)?;
let subagent_type = role_file.role.claude_subagent_type.clone().unwrap_or_else(
|| default_subagent_type(&task.task.role),
);
let isolation = default_isolation(&task.task.role);
let description = build_description(&task.task.role, &task.task.agent_id);
let verify_command = build_verify_command(&task.task.agent_id);
let ledger_row = build_ledger_row(task);
Ok(AgentInvocation {
agent_id: task.task.agent_id.clone(),
role: task.task.role.clone(),
prompt,
subagent_type,
isolation,
description,
verify_command,
ledger_row,
})
}
/// Human-readable block — copy into Claude Code's Agent-tool dialog.
pub fn render_human(inv: &AgentInvocation) -> String {
let iso = inv.isolation.as_deref().unwrap_or("<none>");
let mut out = String::new();
out.push_str("=== AGENT SUBSTRATE v1 — PREPARED SPAWN ===\n");
out.push_str(&format!("agent-id: {}\n", inv.agent_id));
out.push_str(&format!("subagent_type: {}\n", inv.subagent_type));
out.push_str(&format!("isolation: {iso}\n"));
out.push_str(&format!("description: {}\n", inv.description));
out.push_str("\n--- PROMPT (copy into Agent tool `prompt` param) ---\n");
out.push_str(&inv.prompt);
if !inv.prompt.ends_with('\n') {
out.push('\n');
}
out.push_str("--- END PROMPT ---\n\n");
out.push_str("on return:\n");
out.push_str(&format!(" {}\n", inv.verify_command));
out.push_str(" (orchestrator harness returns worktree path in the task-notification)\n\n");
out.push_str(&format!("ledger: {}\n", inv.ledger_row));
out
}
pub fn render_json(inv: &AgentInvocation) -> Result<String> {
serde_json::to_string_pretty(inv).context("serialize AgentInvocation to JSON")
}
pub fn render_toml(inv: &AgentInvocation) -> Result<String> {
toml::to_string_pretty(inv).context("serialize AgentInvocation to TOML")
}
fn default_isolation(role: &str) -> Option<String> {
match role {
"edit-local" | "edit-shared" => Some("worktree".into()),
_ => None,
}
}
fn default_subagent_type(role: &str) -> String {
match role {
"edit-local" | "edit-shared" => "code-implementer",
"explorer" => "Explore",
"read-only" => "critic",
_ => "critic",
}
.into()
}
fn build_description(role: &str, agent_id: &str) -> String {
let short = agent_id.split('-').take(2).collect::<Vec<_>>().join("-");
format!("{role} agent {short}")
}
fn build_verify_command(agent_id: &str) -> String {
format!(
"kei-agent-runtime verify tasks/{id}/task.toml \
--worktree <path-from-harness>",
id = agent_id
)
}
fn build_ledger_row(task: &TaskSpec) -> String {
let parent = task
.task
.parent_agent
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("none");
format!(
"running agent-id={} role={} parent={}",
task.task.agent_id, task.task.role, parent
)
}
fn load_role_file(kit_root: &Path, role: &str) -> Result<RoleFile> {
let path = kit_root.join("_roles").join(format!("{role}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read role file {}", path.display()))?;
toml::from_str::<RoleFile>(&text)
.with_context(|| format!("parse role TOML {}", path.display()))
}
#[derive(Debug, Deserialize)]
struct RoleFile {
#[serde(default)]
role: RoleMeta,
}
#[derive(Debug, Default, Deserialize)]
struct RoleMeta {
#[serde(default = "spawnable_default")]
spawnable: bool,
#[serde(default, rename = "claude-subagent-type")]
claude_subagent_type: Option<String>,
}
fn spawnable_default() -> bool {
true
}

View file

@ -0,0 +1,120 @@
//! Prepare smoke — validates orchestrator-facing wrapper.
//!
//! Three fixtures per task spec:
//! 1. Happy path — valid task.toml → AgentInvocation fully populated
//! 2. Unknown role → clear error (role lookup fails)
//! 3. Non-spawnable role (git-ops) → explicit refusal + RULE 0.13 pointer
use kei_agent_runtime::capability::TaskSpec;
use kei_agent_runtime::prepare::{prepare, render_human};
use tempfile::TempDir;
fn write_capability(root: &std::path::Path, cat: &str, slug: &str, body: &str) {
let dir = root.join("_capabilities").join(cat).join(slug);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("text.md"), body).unwrap();
}
fn write_role(root: &std::path::Path, name: &str, toml: &str) {
std::fs::create_dir_all(root.join("_roles")).unwrap();
std::fs::write(root.join("_roles").join(format!("{name}.toml")), toml).unwrap();
}
#[test]
fn happy_path_yields_full_invocation() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_capability(root, "policy", "no-git-ops", "## Never git.\n");
write_capability(root, "output", "report-format", "## Report fields.\n");
write_role(
root,
"edit-local",
r#"
[role]
name = "edit-local"
spawnable = true
claude-subagent-type = "code-implementer"
[capabilities]
required = ["policy::no-git-ops", "output::report-format"]
"#,
);
let mut task = TaskSpec::default();
task.task.role = "edit-local".into();
task.task.agent_id = "edit-local-forge-abc123".into();
task.body.text = "Port kei-forge templating to pure-Rust.".into();
let inv = prepare(&task, root).expect("prepare should succeed");
assert_eq!(inv.agent_id, "edit-local-forge-abc123");
assert_eq!(inv.role, "edit-local");
assert_eq!(inv.subagent_type, "code-implementer");
assert_eq!(inv.isolation.as_deref(), Some("worktree"));
assert!(inv.prompt.contains("Never git"));
assert!(inv.prompt.contains("Report fields"));
assert!(inv.prompt.contains("Port kei-forge templating"));
assert!(inv.verify_command.contains("kei-agent-runtime verify"));
assert!(inv.verify_command.contains("edit-local-forge-abc123"));
assert!(inv.ledger_row.contains("running"));
assert!(inv.ledger_row.contains("edit-local"));
assert!(inv.ledger_row.contains("parent=none"));
let human = render_human(&inv);
assert!(human.contains("=== AGENT SUBSTRATE v1"));
assert!(human.contains("--- PROMPT"));
assert!(human.contains("--- END PROMPT"));
assert!(human.contains("subagent_type: code-implementer"));
}
#[test]
fn unknown_role_errors_clearly() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let mut task = TaskSpec::default();
task.task.role = "does-not-exist".into();
task.task.agent_id = "x-1".into();
let err = prepare(&task, root).expect_err("unknown role must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("does-not-exist") || msg.contains("role"),
"error should mention the role or the word 'role': got {msg}"
);
}
#[test]
fn non_spawnable_role_refused_with_rule_013_pointer() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"git-ops",
r#"
[role]
name = "git-ops"
spawnable = false
claude-subagent-type = "NOT-SPAWNABLE"
[capabilities]
required = []
"#,
);
let mut task = TaskSpec::default();
task.task.role = "git-ops".into();
task.task.agent_id = "orchestrator-only-1".into();
let err = prepare(&task, root).expect_err("git-ops must be refused");
let msg = format!("{err:#}");
assert!(
msg.contains("RULE 0.13"),
"refusal must cite RULE 0.13: got {msg}"
);
assert!(
msg.contains("spawnable") || msg.contains("orchestrator"),
"refusal message should mention spawnable/orchestrator: got {msg}"
);
}

View file

@ -3,6 +3,9 @@ name = "edit-local"
display-name = "code-implementer (local edit scope)"
description = "Write code within whitelisted files, run cargo check/test, emit structured report. No git, no workspace-level touches, no dep bumps."
spawnable = true
# Default Claude Code subagent_type to hand `prepare` output to.
# Overridable per-task; see docs/AGENT-SUBSTRATE-SCHEMA.md §Orchestrator ergonomics.
claude-subagent-type = "code-implementer"
[capabilities]
# Ordered list — text.md fragments concatenated in this order

View file

@ -3,6 +3,7 @@ name = "edit-shared"
display-name = "code-implementer (shared-SSoT edit scope)"
description = "Same baseline as edit-local, with one relaxed scope entry permitting edits to a task-specified SSoT path (e.g. workspace Cargo.toml, registry file). The relaxation is configured per task via `[scope].files-whitelist` in task.toml."
spawnable = true
claude-subagent-type = "code-implementer"
[capabilities]
# Ordered list — text.md fragments concatenated in this order

View file

@ -3,6 +3,7 @@ name = "explorer"
display-name = "explorer + cargo-check (read-only analyst with build probe)"
description = "Read-only analyst that may run cargo-family commands for build/test introspection. No edits, no git, no non-cargo shell."
spawnable = true
claude-subagent-type = "Explore"
[capabilities]
# Ordered list — text.md fragments concatenated in this order

View file

@ -3,6 +3,8 @@ name = "git-ops"
display-name = "git operator (orchestrator-only, NOT spawnable)"
description = "Documented boundary of git authority. Per RULE 0.13, only the orchestrator (main session) holds git power: branch creation, commit, push, merge, rebase, reset, tag. This role is documented for completeness and is refused by kei-agent-runtime at spawn time."
spawnable = false
# Documented for completeness; never consumed because spawnable = false.
claude-subagent-type = "NOT-SPAWNABLE"
[capabilities]
# No capability restrictions declared here — this role is never composed into

View file

@ -3,6 +3,9 @@ name = "read-only"
display-name = "explorer (read-only analyst)"
description = "Read-only agent: inspects code, emits structured report with severity grades. No shell, no edits, no git."
spawnable = true
# Read-only + severity-grade default maps to critic; architect-flavoured tasks
# should override this per task via claude-subagent-type in task.toml.
claude-subagent-type = "critic"
[capabilities]
# Ordered list — text.md fragments concatenated in this order

View file

@ -547,6 +547,58 @@ Phase 5 wired the 5 kit-shipped agents to role+task-spec invocation via a new `s
Backward compatibility: the `substrate_role` field is optional. The 7 non-migrated kit agents (`kei-cost-guardian`, `kei-fal-ai-runner`, `kei-infra-implementer`, `kei-ml-implementer`, `kei-ml-researcher`, `kei-modal-runner`, `kei-researcher`) continue to assemble without change; a deferred v0.24 migration wave will promote them. Task-spec examples showing how the orchestrator invokes each migrated agent live under `_templates/task-examples/`.
## Orchestrator ergonomics — `prepare` command
`compose` emits a prompt, `spawn` writes `tasks/<id>/` on disk, `verify` runs on return. Between compose and spawn, the orchestrator needs to invoke Claude Code's `Agent` tool — which lives inside Claude Code, not in Rust. `kei-agent-runtime prepare` bridges that step: it parses a `task.toml` and emits every argument the Agent-tool call needs in one copy-paste-ready block.
```
kei-agent-runtime prepare <task.toml> [--kit-root .] [--format human|json|toml]
```
Human output:
```
=== AGENT SUBSTRATE v1 — PREPARED SPAWN ===
agent-id: <id>
subagent_type: <role-derived>
isolation: worktree
description: <role> agent <short>
--- PROMPT (copy into Agent tool `prompt` param) ---
<composed prompt content>
--- END PROMPT ---
on return:
kei-agent-runtime verify tasks/<id>/task.toml --worktree <path-from-harness>
(orchestrator harness returns worktree path in the task-notification)
ledger: running agent-id=<id> role=<role> parent=<parent-or-none>
```
`--format=json` and `--format=toml` emit the same `AgentInvocation` struct for scriptable wrappers (e.g. future `/spawn-agent` Claude Code skill).
### Role → Claude subagent_type mapping
Claude Code's `Agent` tool takes a `subagent_type` string. Roles map to subagent_type via an optional `claude-subagent-type` field on `[role]` in `_roles/<name>.toml`. If unset, the runtime falls back to defaults:
| Role | Default `claude-subagent-type` |
|---|---|
| `edit-local` | `code-implementer` |
| `edit-shared` | `code-implementer` |
| `explorer` | `Explore` |
| `read-only` | `critic` (override per-task for architect-flavour reviews) |
| `git-ops` | `NOT-SPAWNABLE` (never composed — `spawnable = false`) |
`isolation = "worktree"` is auto-set for `edit-local` and `edit-shared`; other roles default to no isolation.
### Non-spawnable refusal
`prepare` refuses roles with `spawnable = false` and cites RULE 0.13 in the error. `git-ops` is the only shipped example; the refusal keeps "who can do git" boundary visible both in the role manifest AND at invocation time.
### Contract
`prepare` does NOT write to disk (inspection helper) and does NOT touch the ledger DB (the "ledger row" field is a pretty-printed string for the orchestrator to verify before calling `kei-ledger fork`). `spawn` remains the disk-writing step; `prepare` is additive and read-only.
## Deferred extension candidates (non-breaking post-lock)
Capability atoms NOT in the initial 10 but good follow-up PRs (non-breaking additions during lock window):