| 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 |