kranz_engine/backend.rs
1//! The agent backend seam (plan §4 "sdk-adapter", §8 testing strategy).
2//!
3//! CONTRACT FILE — do not modify in implementation phases. If a change seems
4//! necessary, report it instead of editing.
5//!
6//! There is no Rust Claude Agent SDK, so the real backend drives the
7//! `claude` CLI headless (`--print --output-format stream-json`), which is
8//! the same process the official SDKs wrap — Claude Code's native config
9//! (CLAUDE.md, .claude/skills, .mcp.json, hooks) is inherited because the
10//! session runs with cwd = repo root. The mock backend returns scripted
11//! event streams so the entire engine is testable without model calls.
12
13use crate::error::Result;
14use crate::types::TokenUsage;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19/// How the session receives its prompt.
20#[derive(Debug, Clone)]
21pub enum PromptMode {
22 /// One-shot: prompt passed at spawn, session ends after the final result.
23 SingleShot(String),
24 /// Streaming input (`--input-format stream-json`): initial prompt sent as
25 /// the first user message; further messages may be injected while live.
26 /// Used by the long-lived orchestrator session (§4.1).
27 Streaming(String),
28}
29
30/// Everything needed to spawn one agent session.
31#[derive(Debug, Clone)]
32pub struct SessionSpec {
33 /// Working directory (target repo root) — project config loads from here.
34 pub cwd: PathBuf,
35 pub prompt: PromptMode,
36 /// Appended to the default Claude Code system prompt (role prompt).
37 ///
38 /// Claude-CLI-ism: `--append-system-prompt` has no equivalent on other
39 /// backends, which may ignore this field.
40 pub append_system_prompt: Option<String>,
41 /// Model alias or full id (passed to --model).
42 pub model: String,
43 /// low | medium | high | xhigh | max (passed to --effort).
44 ///
45 /// Claude-CLI-ism: `--effort` is a Claude Code flag; a non-claude backend
46 /// may ignore this field.
47 pub effort: String,
48 /// Engine-chosen session UUID (passed to --session-id) so resume
49 /// bookkeeping never depends on parsing CLI output.
50 pub session_id: String,
51 /// When set, resume this previous session id instead of starting fresh.
52 ///
53 /// Claude-CLI-ism: session resume is a Claude Code capability; a
54 /// non-claude backend may ignore (or reject) this field.
55 pub resume: Option<String>,
56 /// Passed to --permission-mode (e.g. "acceptEdits", "plan", "dontAsk").
57 ///
58 /// Claude-CLI-ism: `--permission-mode` has no equivalent on other
59 /// backends, which may ignore this field.
60 pub permission_mode: Option<String>,
61 /// Passed to --allowedTools (patterns like "Bash(npm test*)").
62 ///
63 /// Claude-CLI-ism: `--allowedTools` is a Claude Code permission concept; a
64 /// non-claude backend may ignore this field.
65 pub allowed_tools: Vec<String>,
66 /// Passed to --disallowedTools (patterns like "Bash(git push*)").
67 ///
68 /// Claude-CLI-ism: `--disallowedTools` is a Claude Code permission
69 /// concept; a non-claude backend may ignore this field.
70 pub disallowed_tools: Vec<String>,
71 /// Passed to `--tools` (the built-in exclusive tool allow-list): empty = CLI default set, no flag emitted.
72 /// Distinct from `allowed_tools`/`disallowed_tools`, which are permission patterns.
73 ///
74 /// Claude-CLI-ism: `--tools` is a Claude Code flag; a non-claude backend
75 /// may ignore this field.
76 pub tools: Vec<String>,
77 /// Whether this session's role is expected to edit the working tree.
78 ///
79 /// Non-Claude backends use this to select a write-capable local workspace
80 /// mode for workers while keeping validators and orchestrators read-only.
81 /// Claude continues to use `permission_mode`/tool rules and ignores this.
82 pub writable: bool,
83 /// Extra settings JSON (hooks etc.) passed via --settings.
84 ///
85 /// Claude-CLI-ism: `--settings` (hooks, etc.) is a Claude Code concept; a
86 /// non-claude backend may ignore this field.
87 pub settings_json: Option<serde_json::Value>,
88 /// JSON Schema enforced on the session's structured output (--json-schema).
89 ///
90 /// Claude-CLI-ism: `--json-schema` is a Claude Code flag; a non-claude
91 /// backend may ignore this field.
92 pub json_schema: Option<serde_json::Value>,
93 /// Hard dollar cap for the run (--max-budget-usd).
94 ///
95 /// Claude-CLI-ism: `--max-budget-usd` is a Claude Code flag; a non-claude
96 /// backend may ignore this field (and should enforce cost caps engine-side
97 /// via its own pricing table instead).
98 pub max_budget_usd: Option<f64>,
99 /// Soft turn budget: the engine counts assistant turns and aborts the
100 /// session when exceeded (the CLI no longer has --max-turns).
101 pub max_turns: Option<u32>,
102 /// Extra environment variables for the child process.
103 pub env: HashMap<String, String>,
104 /// Resolved OS sandbox, when the role opted into enforcement and the
105 /// platform supports it (`None` otherwise).
106 ///
107 /// Additive field (docs/scoping/worker-sandboxing.md): populated by
108 /// `runner.rs` for worker/validator sessions. Non-claude backends ignore it.
109 pub sandbox: Option<crate::sandbox::ResolvedSandbox>,
110 /// Hook-status lane seed (ticket `agent-hooks-status-signals`): when the
111 /// mission config opts in AND the role's backend is hook-capable
112 /// ([`crate::types::BackendKind::supports_hook_status_signals`]), the
113 /// runner registers a per-run capability token and carries it here so
114 /// the backend can install its lifecycle-hook projection at spawn.
115 /// Backends without a hook surface ignore this field exactly like
116 /// `settings_json` — a `Some` on them is a byte-identical no-op.
117 pub hook_status: Option<crate::hook_status::HookStatusSeed>,
118}
119
120/// Normalized events surfaced from a session's output stream.
121///
122/// `raw` always carries the full original stream-json line for transcript
123/// fidelity; the variants extract only what the engine acts on.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase", tag = "kind")]
126pub enum AgentEvent {
127 /// First message of a session (`type: "system", subtype: "init"`).
128 Init {
129 session_id: String,
130 model: String,
131 raw: serde_json::Value,
132 },
133 /// Assistant text output.
134 Text {
135 text: String,
136 raw: serde_json::Value,
137 },
138 /// Assistant requested a tool.
139 ToolUse {
140 tool: String,
141 /// Compact human-readable summary of the input (e.g. the Bash command).
142 summary: String,
143 raw: serde_json::Value,
144 },
145 /// Tool result returned to the model. `denied` is true when the call was
146 /// blocked by permission rules — surfaced so the dashboard shows
147 /// guardrails firing (§4.7).
148 ToolResult {
149 tool: Option<String>,
150 denied: bool,
151 summary: String,
152 raw: serde_json::Value,
153 },
154 /// Terminal result (`type: "result"`). Exactly one per completed turn in
155 /// single-shot mode; in streaming mode one per injected turn.
156 Result {
157 /// Final result text (worker report JSON lives here when --json-schema
158 /// was set).
159 text: String,
160 is_error: bool,
161 usage: TokenUsage,
162 /// Cost attributable to this result, not a cumulative session total.
163 cost_usd: Option<f64>,
164 num_turns: Option<u32>,
165 raw: serde_json::Value,
166 },
167 /// Anything else (kept for transcripts; engine ignores).
168 Other { raw: serde_json::Value },
169}
170
171/// Why a session ended.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum SessionExit {
175 /// Process exited normally after a result message.
176 Completed,
177 /// Aborted by the engine (interrupt, turn budget, shutdown).
178 Aborted,
179 /// Process died or emitted an error result.
180 Failed(String),
181}
182
183/// A live agent session. Consumers poll `next_event` until `None`, then call
184/// `exit_status`.
185#[async_trait::async_trait]
186pub trait AgentSession: Send {
187 /// The session id actually in use (== spec.session_id unless resumed).
188 fn session_id(&self) -> String;
189
190 /// Next event from the session stream; `None` when the stream is closed.
191 async fn next_event(&mut self) -> Result<Option<AgentEvent>>;
192
193 /// Inject a user message (streaming-input sessions only; error otherwise).
194 async fn send_user_message(&mut self, text: &str) -> Result<()>;
195
196 /// Terminate the underlying process/stream. Must kill the whole process
197 /// tree and work on Windows (no bare POSIX signal assumptions — §9).
198 async fn abort(&mut self) -> Result<()>;
199
200 /// Available after the stream has closed.
201 fn exit_status(&self) -> Option<SessionExit>;
202}
203
204/// Factory for agent sessions — the mockable seam.
205#[async_trait::async_trait]
206pub trait AgentBackend: Send + Sync {
207 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>>;
208}