返回 CodeWhale
driver.rs
根目录 / crates / workflow-js / src / driver.rs
1 //! The driver seam between the sandboxed VM and the subagent engine.
2 //!
3 //! The QuickJS VM lives on a dedicated thread and its `'js` values can never
4 //! cross an `.await` onto another thread, so everything that leaves the VM is
5 //! plain `Send` data: a [`TaskRequest`] goes out, a [`TaskCompletion`] comes
6 //! back over a oneshot. The [`WorkflowDriver`] trait is the host-side contract
7 //! the tui wiring implements over `SubAgentManager` (spawn is fire-and-forget
8 //! there; the driver's completion pump resolves the oneshot from the mailbox
9 //! `Completed` signal keyed by `agent_id`, then reads the full untruncated
10 //! text via `get_result`). Tests implement it with
11 //! [`crate::testing::FakeDriver`].
12 //!
13 //! Budget ownership: token accounting and the §5.3 reservation semantics live
14 //! entirely on the driver side (the manager's budget scopes). The VM only
15 //! reads [`BudgetSnapshot`]s — it performs a fast-fail `spent >= total` check
16 //! before spawning and exposes the numbers to JS as `budget.*`, but it never
17 //! reserves or debits tokens itself. A driver that admits a spawn is the
18 //! authority; its rejection surfaces as a JS throw on that `task()` call.
19
20 use async_trait::async_trait;
21 use serde::{Deserialize, Serialize};
22 use tokio::sync::oneshot;
23
24 use crate::error::DriverError;
25
26 /// One `task()` invocation, fully resolved and validated on the VM side.
27 ///
28 /// Field semantics mirror the `agent` tool's spawn options.
29 ///
30 /// Step identity is fleet `role` (preferred) and/or `profile` (#4177). Both
31 /// tokens are normalized (trimmed + lowercased) with the same rule as
32 /// `crates/workflow` leaf profiles. Roster membership is resolved by the
33 /// driver (tui) at spawn time — this crate never sees the saved Fleet roster.
34 /// Provider/model remain optional overrides, not required identity fields.
35 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36 pub struct TaskRequest {
37 /// The child prompt (JS `prompt`, falling back to `description`; required).
38 pub description: String,
39 /// Subagent type (JS `subagentType` or `type`); `None` lets the driver
40 /// apply its default (`general`).
41 pub subagent_type: Option<String>,
42 /// Fleet role name (JS `role`), e.g. `scout` / `implementer` (#4177).
43 pub role: Option<String>,
44 /// Fleet profile token, normalized (trimmed, lowercased) and validated.
45 /// Explicit profile wins over role mapping at spawn time.
46 pub profile: Option<String>,
47 /// Explicit model override; always wins over `model_strength`.
48 pub model: Option<String>,
49 /// Relative model strength (`same`/`faster`, plus driver-side aliases).
50 pub model_strength: Option<String>,
51 /// Reasoning effort (`inherit`/`off`/`low`/`medium`/`high`/`max`).
52 pub thinking: Option<String>,
53 /// Optional existing working directory, relative to the parent workspace.
54 /// The host validates that it exists and remains inside the workspace.
55 pub cwd: Option<String>,
56 /// Run the child in a fresh git worktree for parallel edits.
57 pub worktree: bool,
58 /// Explicit child mutation authority. A write-capable value remains
59 /// fail-closed unless at least one bounded scope below is declared.
60 #[serde(default)]
61 pub write_authority: Option<String>,
62 /// Repo-relative directory trees the child expects to mutate.
63 #[serde(default)]
64 pub write_roots: Vec<String>,
65 /// Repo-relative exact files the child expects to mutate.
66 #[serde(default)]
67 pub exact_files: Vec<String>,
68 /// Named shared contracts owned by this child while active.
69 #[serde(default)]
70 pub coordination_contracts: Vec<String>,
71 /// Bounded prerequisite facts relevant to this child.
72 #[serde(default)]
73 pub dependencies: Vec<String>,
74 /// Bounded observable checks for child completion.
75 #[serde(default)]
76 pub acceptance: Vec<String>,
77 /// Explicit tool allowlist; required by the driver for `custom` roles.
78 pub allowed_tools: Option<Vec<String>>,
79 /// Host-imposed tool deny list. Deny always wins over allow, including over
80 /// `allowed_tools` and over the role posture.
81 ///
82 /// Deliberately **not** settable from a workflow script: it is how a host
83 /// enforces a ceiling it derived (an exact Fleet member's
84 /// `network_tool = false`, for instance) on the child that actually runs.
85 /// A script that could write it could also clear it.
86 #[serde(default)]
87 pub disallowed_tools: Vec<String>,
88 /// Per-call spawn-depth override (driver clamps to its ceiling).
89 pub max_depth: Option<u32>,
90 /// Explicit token budget: forks an isolated pool on the driver side.
91 /// Omit it so the child inherits (and debits) the shared run pool.
92 pub token_budget: Option<u64>,
93 /// Maximum model turns for this child (driver clamps to its ceiling).
94 pub max_steps: Option<u32>,
95 /// Hard wall-clock limit for this child in seconds.
96 pub wall_time_secs: Option<u64>,
97 /// JSON schema the reply must satisfy; validated in the VM after the
98 /// driver returns the raw text (see [`crate`] docs for decode rules).
99 pub response_schema: Option<serde_json::Value>,
100 /// Bounded repair attempts after a failed `responseSchema` decode
101 /// (#5583): re-ask the same route with the schema and the failed reply.
102 /// `None` uses the default of one attempt; `Some(0)` disables repair.
103 /// Clamped at parse time to [`crate::SCHEMA_REPAIR_MAX_ATTEMPTS`]. The driver
104 /// treats a repair spawn like any other task — same admission, budget,
105 /// and usage accounting.
106 pub schema_repair_attempts: Option<u32>,
107 /// Short human label for progress surfaces.
108 pub label: Option<String>,
109 /// Phase name this task belongs to, for progress grouping.
110 pub phase: Option<String>,
111 }
112
113 /// Terminal outcome of one spawned task, delivered over the completion
114 /// oneshot. Everything except `Completed` becomes a JS throw on the awaiting
115 /// `task()` call.
116 #[derive(Debug, Clone, PartialEq, Eq)]
117 pub enum TaskCompletion {
118 /// The child finished; `text` is the full, untruncated result.
119 Completed { text: String },
120 /// The child failed (error result, timeout, ...).
121 Failed { message: String },
122 /// The child was cancelled (cascade or explicit).
123 Cancelled,
124 /// The child's budget scope drained mid-flight.
125 BudgetExhausted { message: String },
126 }
127
128 /// A successfully admitted spawn: the driver-assigned task id (the engine's
129 /// `agent_id`) plus the oneshot the driver resolves on completion.
130 ///
131 /// Dropping the receiver must not wedge the driver; drivers should treat a
132 /// closed completion channel as "nobody is listening" and move on.
133 #[derive(Debug)]
134 pub struct SpawnedTask {
135 /// Driver-assigned id, unique within the run (engine `agent_id`).
136 pub task_id: String,
137 /// Resolved exactly once with the terminal [`TaskCompletion`].
138 pub completion: oneshot::Receiver<TaskCompletion>,
139 }
140
141 /// Live view of the run's shared token pool, owned by the driver.
142 ///
143 /// `total == None` means no ceiling is configured; JS then sees
144 /// `budget.total === null` and `budget.remaining() === Infinity`.
145 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146 pub struct BudgetSnapshot {
147 /// Pool ceiling in tokens, if one is configured.
148 pub total: Option<u64>,
149 /// Tokens spent (plus driver-side reservations) against the pool.
150 pub spent: u64,
151 }
152
153 impl BudgetSnapshot {
154 /// Tokens left before the ceiling; `None` when the pool is unbounded.
155 pub fn remaining(&self) -> Option<u64> {
156 self.total.map(|total| total.saturating_sub(self.spent))
157 }
158
159 /// True once the pool has a ceiling and it is fully consumed.
160 pub fn exhausted(&self) -> bool {
161 matches!(self.total, Some(total) if self.spent >= total)
162 }
163 }
164
165 /// Progress events emitted by the script (`log(..)` / `phase(..)`), delivered
166 /// to the driver synchronously and in script order.
167 #[derive(Debug, Clone, PartialEq, Eq)]
168 pub enum ProgressEvent {
169 /// `log(msg)` — a narrator line for the UI.
170 Log {
171 /// The stringified message.
172 message: String,
173 },
174 /// `phase(title)` — the script entered a named phase.
175 Phase {
176 /// The phase title.
177 title: String,
178 },
179 /// A completed child returned text that failed the caller's
180 /// `responseSchema` and no repair remains (none configured, or every
181 /// repair attempt also failed — #5583). The VM emits this before
182 /// throwing the validation error back into the script so host-side
183 /// receipts can mark the leaf as failed instead of reporting a
184 /// successful child beside a `null` result. This event is terminal:
185 /// it fires exactly once per failed `task()`, for the attempt that
186 /// failed last, carrying every earlier attempt through
187 /// [`ProgressEvent::TaskSchemaRepairAttempted`].
188 TaskSchemaValidationFailed {
189 /// Driver-assigned task id (engine `agent_id`) of the failed attempt.
190 task_id: String,
191 /// Which decode stage failed: `json_parse` or `schema_validation`.
192 kind: String,
193 /// 1-based attempt number that failed terminally (1 = no repair
194 /// was tried; 2+ = repairs were tried and also failed).
195 attempt: u32,
196 /// The validation error already surfaced to JS.
197 message: String,
198 /// Raw reply text of the failed attempt, bounded by
199 /// [`crate::SCHEMA_RAW_CARRY_CHARS`]; the host writes the durable artifact.
200 raw: String,
201 /// True when `raw` was capped at the carry limit.
202 raw_truncated: bool,
203 },
204 /// A completed child failed its `responseSchema` and a bounded repair
205 /// will follow (#5583). Emitted before the repair spawn so the failed
206 /// attempt is visible even when the repair succeeds and the `task()`
207 /// call returns normally.
208 TaskSchemaRepairAttempted {
209 /// Driver-assigned task id (engine `agent_id`) of the failed attempt
210 /// that triggered this repair.
211 task_id: String,
212 /// Which decode stage failed: `json_parse` or `schema_validation`.
213 kind: String,
214 /// 1-based number of the attempt that failed.
215 attempt: u32,
216 /// The decode error of that attempt.
217 message: String,
218 /// Raw reply text of that attempt, bounded by
219 /// [`crate::SCHEMA_RAW_CARRY_CHARS`].
220 raw: String,
221 /// True when `raw` was capped at the carry limit.
222 raw_truncated: bool,
223 },
224 /// A `task()` call was rejected before any child agent existed —
225 /// malformed options, a bad `responseSchema`, the lifetime cap, or an
226 /// exhausted budget. Inside `parallel()` the JS throw collapses to a
227 /// `null` slot, so without this event the host would have no record that
228 /// a slot was ever requested; drivers fold it into the same ledger as
229 /// `spawn_task` rejections so run status can stay honest.
230 TaskRejected {
231 /// Best-effort `label` from the raw options, when parseable.
232 label: Option<String>,
233 /// Best-effort `phase` from the raw options, when parseable.
234 phase: Option<String>,
235 /// The rejection already surfaced to JS.
236 message: String,
237 },
238 /// A `parallel()` / `pipeline()` fan-out resolved with every slot failed
239 /// and nothing surviving (R9). Emitted by the prelude beside its run-log
240 /// breadcrumb. A fan-out of thunks that throw without calling `task()`
241 /// leaves no task record and no dispatch failure behind, so a log line
242 /// was the only trace — and a log line cannot feed the status
243 /// classifier. This event is the structured twin of that breadcrumb:
244 /// drivers count it so the terminal-status ledger can refuse to record
245 /// such a run as a plain success.
246 FanoutAllSlotsFailed {
247 /// Which fan-out construct died: `parallel` or `pipeline`.
248 construct: String,
249 /// Slots that failed (always equal to `total`).
250 failed: u32,
251 /// Total slots the fan-out declared.
252 total: u32,
253 },
254 /// ONE slot of a settled fan-out dropped to null while other slots
255 /// survived. The structured twin of the per-slot breadcrumb: the ledger
256 /// counts these so a PARTIALLY failed fan-out records Degraded instead
257 /// of a clean Completed with silently lost work.
258 FanoutSlotDropped {
259 /// Which fan-out construct dropped it: `parallel` or `pipeline`.
260 construct: String,
261 /// Typed failure kind (`script`, `task`, `schema`, ...).
262 kind: String,
263 /// Zero-based slot index.
264 slot: u32,
265 },
266 }
267
268 /// Host-side executor for a Workflow run.
269 ///
270 /// Implementations must be cheap to call from the VM thread: `spawn_task`
271 /// admits the task and returns immediately (fire-and-forget spawn — never
272 /// await the child inline), while `budget`, `progress`, and `cancel_all` are
273 /// synchronous. `cancel_all` must be idempotent; it is invoked when the
274 /// script errors, when the run future is dropped, and once more never hurts.
275 #[async_trait]
276 pub trait WorkflowDriver: Send + Sync {
277 /// Admit and start one task. Errors surface as a JS throw on the
278 /// corresponding `task()` call.
279 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError>;
280
281 /// Cancel every in-flight task belonging to this run. Idempotent.
282 fn cancel_all(&self);
283
284 /// Current snapshot of the run's shared token pool.
285 fn budget(&self) -> BudgetSnapshot;
286
287 /// Receive a script progress event (ordered, synchronous).
288 fn progress(&self, event: ProgressEvent);
289 }
290
291 /// One `tools.call()` invocation: a tool name plus its JSON arguments.
292 ///
293 /// Unlike [`TaskRequest`], this carries no identity, route, or authority
294 /// fields. Authority comes from the host side alone: the invoker resolves the
295 /// name against its own registry snapshot, enforces the parent turn's
296 /// deny-lists and authority envelope, and applies the run profile's gates
297 /// (read-only, auto-approve). A script can neither widen nor name its own
298 /// ceiling.
299 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300 pub struct ToolCallRequest {
301 /// Tool name as advertised by `tool_search` (e.g. `read`).
302 pub tool: String,
303 /// Arguments for the tool. Must be a JSON object.
304 pub input: serde_json::Value,
305 }
306
307 /// Terminal outcome of one `tools.call()`, as JSON the VM passes to JS.
308 ///
309 /// A resolved call either ran clean (`ok: true`, `result` is the tool
310 /// payload) or ran and failed (`ok: false`, `result` is the failure
311 /// message). Gate refusals and seam breaks never arrive here — those are
312 /// `Err`, so the VM can tell "nothing ran" (admission) from "work failed".
313 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314 pub struct ToolCallResponse {
315 /// True when the tool ran clean. False carries the failure message.
316 pub ok: bool,
317 /// Tool payload on success; failure message string on failure.
318 pub result: serde_json::Value,
319 }
320
321 /// Host-side executor for direct tool calls from a script.
322 ///
323 /// This is the tool analogue of [`WorkflowDriver`]: where the driver launches
324 /// another agent/model task, the invoker executes one ordinary tool and
325 /// returns its result. The two must not be confused — in particular, an
326 /// invoker must never implement a call by spawning a subagent.
327 ///
328 /// Gate refusals (unknown tool, read-only violation, approval requirement,
329 /// recursion) surface as [`DriverError::Rejected`]; a broken seam surfaces
330 /// as [`DriverError::Unavailable`].
331 #[async_trait]
332 pub trait ToolInvoker: Send + Sync {
333 /// Execute one tool call and return its outcome.
334 async fn invoke(&self, request: ToolCallRequest) -> Result<ToolCallResponse, DriverError>;
335 }
336
337 /// Normalize and validate a Fleet profile token: trim, lowercase, then apply
338 /// the same token rule as `crates/workflow`'s `validate_leaf_profile` —
339 /// non-empty, no whitespace, and none of `"`, `'`, `` ` ``, `=`.
340 pub fn normalize_profile(raw: &str) -> Result<String, String> {
341 let normalized = raw.trim().to_lowercase();
342 let invalid = normalized.is_empty()
343 || normalized
344 .chars()
345 .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
346 if invalid {
347 return Err(format!(
348 "invalid profile token {raw:?}: profiles must be non-empty and contain no whitespace, quotes, backticks, or '='"
349 ));
350 }
351 Ok(normalized)
352 }
353
354 #[cfg(test)]
355 mod tests {
356 use super::*;
357
358 #[test]
359 fn normalize_profile_trims_and_lowercases() {
360 assert_eq!(normalize_profile(" ALpha-1 ").unwrap(), "alpha-1");
361 }
362
363 #[test]
364 fn normalize_profile_rejects_bad_tokens() {
365 for bad in ["", " ", "two words", "a=b", "a\"b", "a'b", "a`b"] {
366 assert!(
367 normalize_profile(bad).is_err(),
368 "expected rejection: {bad:?}"
369 );
370 }
371 }
372
373 #[test]
374 fn budget_snapshot_math() {
375 let unbounded = BudgetSnapshot {
376 total: None,
377 spent: 10,
378 };
379 assert_eq!(unbounded.remaining(), None);
380 assert!(!unbounded.exhausted());
381
382 let pool = BudgetSnapshot {
383 total: Some(100),
384 spent: 40,
385 };
386 assert_eq!(pool.remaining(), Some(60));
387 assert!(!pool.exhausted());
388
389 let drained = BudgetSnapshot {
390 total: Some(100),
391 spent: 120,
392 };
393 assert_eq!(drained.remaining(), Some(0));
394 assert!(drained.exhausted());
395 }
396
397 #[test]
398 fn legacy_task_request_defaults_new_coordination_fields() {
399 let legacy = serde_json::json!({
400 "description": "inspect the candidate",
401 "subagent_type": null,
402 "role": "reviewer",
403 "profile": null,
404 "model": null,
405 "model_strength": null,
406 "thinking": null,
407 "cwd": null,
408 "worktree": false,
409 "allowed_tools": null,
410 "max_depth": null,
411 "token_budget": null,
412 "max_steps": null,
413 "wall_time_secs": null,
414 "response_schema": null,
415 "label": null,
416 "phase": null
417 });
418
419 let request: TaskRequest = serde_json::from_value(legacy).unwrap();
420 assert_eq!(request.write_authority, None);
421 assert!(request.write_roots.is_empty());
422 assert!(request.exact_files.is_empty());
423 assert!(request.coordination_contracts.is_empty());
424 assert!(request.dependencies.is_empty());
425 assert!(request.acceptance.is_empty());
426 }
427 }
428
428 lines RUST