Skip to main content

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}