| 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 | /// Short human label for progress surfaces. |
| 101 | pub label: Option<String>, |
| 102 | /// Phase name this task belongs to, for progress grouping. |
| 103 | pub phase: Option<String>, |
| 104 | } |
| 105 | |
| 106 | /// Terminal outcome of one spawned task, delivered over the completion |
| 107 | /// oneshot. Everything except `Completed` becomes a JS throw on the awaiting |
| 108 | /// `task()` call. |
| 109 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 110 | pub enum TaskCompletion { |
| 111 | /// The child finished; `text` is the full, untruncated result. |
| 112 | Completed { text: String }, |
| 113 | /// The child failed (error result, timeout, ...). |
| 114 | Failed { message: String }, |
| 115 | /// The child was cancelled (cascade or explicit). |
| 116 | Cancelled, |
| 117 | /// The child's budget scope drained mid-flight. |
| 118 | BudgetExhausted { message: String }, |
| 119 | } |
| 120 | |
| 121 | /// A successfully admitted spawn: the driver-assigned task id (the engine's |
| 122 | /// `agent_id`) plus the oneshot the driver resolves on completion. |
| 123 | /// |
| 124 | /// Dropping the receiver must not wedge the driver; drivers should treat a |
| 125 | /// closed completion channel as "nobody is listening" and move on. |
| 126 | #[derive(Debug)] |
| 127 | pub struct SpawnedTask { |
| 128 | /// Driver-assigned id, unique within the run (engine `agent_id`). |
| 129 | pub task_id: String, |
| 130 | /// Resolved exactly once with the terminal [`TaskCompletion`]. |
| 131 | pub completion: oneshot::Receiver<TaskCompletion>, |
| 132 | } |
| 133 | |
| 134 | /// Live view of the run's shared token pool, owned by the driver. |
| 135 | /// |
| 136 | /// `total == None` means no ceiling is configured; JS then sees |
| 137 | /// `budget.total === null` and `budget.remaining() === Infinity`. |
| 138 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 139 | pub struct BudgetSnapshot { |
| 140 | /// Pool ceiling in tokens, if one is configured. |
| 141 | pub total: Option<u64>, |
| 142 | /// Tokens spent (plus driver-side reservations) against the pool. |
| 143 | pub spent: u64, |
| 144 | } |
| 145 | |
| 146 | impl BudgetSnapshot { |
| 147 | /// Tokens left before the ceiling; `None` when the pool is unbounded. |
| 148 | pub fn remaining(&self) -> Option<u64> { |
| 149 | self.total.map(|total| total.saturating_sub(self.spent)) |
| 150 | } |
| 151 | |
| 152 | /// True once the pool has a ceiling and it is fully consumed. |
| 153 | pub fn exhausted(&self) -> bool { |
| 154 | matches!(self.total, Some(total) if self.spent >= total) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// Progress events emitted by the script (`log(..)` / `phase(..)`), delivered |
| 159 | /// to the driver synchronously and in script order. |
| 160 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 161 | pub enum ProgressEvent { |
| 162 | /// `log(msg)` — a narrator line for the UI. |
| 163 | Log { |
| 164 | /// The stringified message. |
| 165 | message: String, |
| 166 | }, |
| 167 | /// `phase(title)` — the script entered a named phase. |
| 168 | Phase { |
| 169 | /// The phase title. |
| 170 | title: String, |
| 171 | }, |
| 172 | /// A completed child returned text that failed the caller's |
| 173 | /// `responseSchema`. The VM emits this before throwing the validation |
| 174 | /// error back into the script so host-side receipts can mark the leaf as |
| 175 | /// failed instead of reporting a successful child beside a `null` result. |
| 176 | TaskSchemaValidationFailed { |
| 177 | /// Driver-assigned task id (engine `agent_id`). |
| 178 | task_id: String, |
| 179 | /// The validation error already surfaced to JS. |
| 180 | message: String, |
| 181 | }, |
| 182 | /// A `task()` call was rejected before any child agent existed — |
| 183 | /// malformed options, a bad `responseSchema`, the lifetime cap, or an |
| 184 | /// exhausted budget. Inside `parallel()` the JS throw collapses to a |
| 185 | /// `null` slot, so without this event the host would have no record that |
| 186 | /// a slot was ever requested; drivers fold it into the same ledger as |
| 187 | /// `spawn_task` rejections so run status can stay honest. |
| 188 | TaskRejected { |
| 189 | /// Best-effort `label` from the raw options, when parseable. |
| 190 | label: Option<String>, |
| 191 | /// Best-effort `phase` from the raw options, when parseable. |
| 192 | phase: Option<String>, |
| 193 | /// The rejection already surfaced to JS. |
| 194 | message: String, |
| 195 | }, |
| 196 | } |
| 197 | |
| 198 | /// Host-side executor for a Workflow run. |
| 199 | /// |
| 200 | /// Implementations must be cheap to call from the VM thread: `spawn_task` |
| 201 | /// admits the task and returns immediately (fire-and-forget spawn — never |
| 202 | /// await the child inline), while `budget`, `progress`, and `cancel_all` are |
| 203 | /// synchronous. `cancel_all` must be idempotent; it is invoked when the |
| 204 | /// script errors, when the run future is dropped, and once more never hurts. |
| 205 | #[async_trait] |
| 206 | pub trait WorkflowDriver: Send + Sync { |
| 207 | /// Admit and start one task. Errors surface as a JS throw on the |
| 208 | /// corresponding `task()` call. |
| 209 | async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError>; |
| 210 | |
| 211 | /// Cancel every in-flight task belonging to this run. Idempotent. |
| 212 | fn cancel_all(&self); |
| 213 | |
| 214 | /// Current snapshot of the run's shared token pool. |
| 215 | fn budget(&self) -> BudgetSnapshot; |
| 216 | |
| 217 | /// Receive a script progress event (ordered, synchronous). |
| 218 | fn progress(&self, event: ProgressEvent); |
| 219 | } |
| 220 | |
| 221 | /// Normalize and validate a Fleet profile token: trim, lowercase, then apply |
| 222 | /// the same token rule as `crates/workflow`'s `validate_leaf_profile` — |
| 223 | /// non-empty, no whitespace, and none of `"`, `'`, `` ` ``, `=`. |
| 224 | pub fn normalize_profile(raw: &str) -> Result<String, String> { |
| 225 | let normalized = raw.trim().to_lowercase(); |
| 226 | let invalid = normalized.is_empty() |
| 227 | || normalized |
| 228 | .chars() |
| 229 | .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '=')); |
| 230 | if invalid { |
| 231 | return Err(format!( |
| 232 | "invalid profile token {raw:?}: profiles must be non-empty and contain no whitespace, quotes, backticks, or '='" |
| 233 | )); |
| 234 | } |
| 235 | Ok(normalized) |
| 236 | } |
| 237 | |
| 238 | #[cfg(test)] |
| 239 | mod tests { |
| 240 | use super::*; |
| 241 | |
| 242 | #[test] |
| 243 | fn normalize_profile_trims_and_lowercases() { |
| 244 | assert_eq!(normalize_profile(" ALpha-1 ").unwrap(), "alpha-1"); |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn normalize_profile_rejects_bad_tokens() { |
| 249 | for bad in ["", " ", "two words", "a=b", "a\"b", "a'b", "a`b"] { |
| 250 | assert!( |
| 251 | normalize_profile(bad).is_err(), |
| 252 | "expected rejection: {bad:?}" |
| 253 | ); |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn budget_snapshot_math() { |
| 259 | let unbounded = BudgetSnapshot { |
| 260 | total: None, |
| 261 | spent: 10, |
| 262 | }; |
| 263 | assert_eq!(unbounded.remaining(), None); |
| 264 | assert!(!unbounded.exhausted()); |
| 265 | |
| 266 | let pool = BudgetSnapshot { |
| 267 | total: Some(100), |
| 268 | spent: 40, |
| 269 | }; |
| 270 | assert_eq!(pool.remaining(), Some(60)); |
| 271 | assert!(!pool.exhausted()); |
| 272 | |
| 273 | let drained = BudgetSnapshot { |
| 274 | total: Some(100), |
| 275 | spent: 120, |
| 276 | }; |
| 277 | assert_eq!(drained.remaining(), Some(0)); |
| 278 | assert!(drained.exhausted()); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn legacy_task_request_defaults_new_coordination_fields() { |
| 283 | let legacy = serde_json::json!({ |
| 284 | "description": "inspect the candidate", |
| 285 | "subagent_type": null, |
| 286 | "role": "reviewer", |
| 287 | "profile": null, |
| 288 | "model": null, |
| 289 | "model_strength": null, |
| 290 | "thinking": null, |
| 291 | "cwd": null, |
| 292 | "worktree": false, |
| 293 | "allowed_tools": null, |
| 294 | "max_depth": null, |
| 295 | "token_budget": null, |
| 296 | "max_steps": null, |
| 297 | "wall_time_secs": null, |
| 298 | "response_schema": null, |
| 299 | "label": null, |
| 300 | "phase": null |
| 301 | }); |
| 302 | |
| 303 | let request: TaskRequest = serde_json::from_value(legacy).unwrap(); |
| 304 | assert_eq!(request.write_authority, None); |
| 305 | assert!(request.write_roots.is_empty()); |
| 306 | assert!(request.exact_files.is_empty()); |
| 307 | assert!(request.coordination_contracts.is_empty()); |
| 308 | assert!(request.dependencies.is_empty()); |
| 309 | assert!(request.acceptance.is_empty()); |
| 310 | } |
| 311 | } |
| 312 |