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