clawft-core 0.8.3

Core engine for clawft (agent loop, message bus, context management)
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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Subagent spawn seam (M4 — agent-initiated work).
//!
//! The conversational agent must be able to kick off work from inside a
//! turn — spawn a subagent, fan out a swarm, hand a task off — and have the
//! result flow back into the conversation as first-class ECC state. This
//! module defines the **injection seam** that makes that possible without a
//! layering violation.
//!
//! ## Layering (design D2)
//!
//! `clawft-tools` (the LLM tool world) sits *below* `clawft-weave` /
//! `clawft-service-agent` (the daemon), so a tool cannot name `AgentService`.
//! The [`SubagentSpawner`] trait inverts that dependency: the tool holds an
//! `Arc<dyn SubagentSpawner>`, and the concrete `DaemonSubagentSpawner` (which
//! *can* reach `AgentService::dispatch`) is injected at tool-registration time,
//! exactly like the existing `ConversationSink` / `chat_gate` handles. The
//! spawner holds only a `Weak` back-reference to the service, breaking the Arc
//! cycle (`AgentService` → tool registry → tool → spawner → `AgentService`).
//!
//! ## Backend tiering (design D2, K2 D2/D3)
//!
//! [`SpawnSpec::backend`] mirrors the kernel's `SpawnBackend`. M4 implements
//! only [`SpawnBackend::Conversation`] — a subagent is a daemon-hosted child
//! conversation on the same M2 commit pipeline (design D1). Every other variant
//! returns [`SpawnError::BackendNotAvailable`], exactly like
//! `AgentSupervisor::spawn`, so the tier boundary is crystallized now and wired
//! incrementally later.

use std::time::Duration;

use async_trait::async_trait;
use clawft_types::config::CostBudgetConfig;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Execution backend for a spawned subagent.
///
/// Mirrors the kernel's `SpawnBackend` (`clawft-kernel::supervisor`) but adds
/// [`Conversation`](SpawnBackend::Conversation) as the default M4 tier: a child
/// conversation on the daemon's multiplexed `AgentLoop`, inheriting the entire
/// M2 pipeline (durable JSONL sink, chain anchoring, `SessionTier`, gate, forest
/// commit, idle reaping). All heavier tiers are seam-only in M4 and return
/// [`SpawnError::BackendNotAvailable`].
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpawnBackend {
    /// Daemon-hosted child conversation (M4 default — the only implemented tier).
    #[default]
    Conversation,
    /// Tokio task with a kernel-supervised agent loop (escalation, deferred).
    Native,
    /// WASM sandbox via Wasmtime (K3).
    Wasm {
        /// Path to the compiled WASM module.
        module: String,
    },
    /// Docker/Podman container (K4).
    Container {
        /// Container image reference.
        image: String,
    },
    /// Trusted Execution Environment — SGX, TrustZone, SEV (K6+).
    Tee {
        /// Enclave type: "sgx", "trustzone", "sev".
        enclave_type: String,
    },
    /// Delegate to a remote node in the cluster (K6, mesh).
    Remote {
        /// Cluster node identifier.
        node_id: String,
    },
}

impl SpawnBackend {
    /// Short kind label for witnessing / error messages.
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Conversation => "conversation",
            Self::Native => "native",
            Self::Wasm { .. } => "wasm",
            Self::Container { .. } => "container",
            Self::Tee { .. } => "tee",
            Self::Remote { .. } => "remote",
        }
    }
}

/// Optional per-child budget overrides (design D5 / WEFT-631). Any `None`
/// field falls back to the daemon's [`CostBudgetConfig`] default for that
/// dimension. Enforcement is applied by seeding the shared
/// [`crate::agent::cost_budget::ConversationBudget`] before the child
/// conversation is dispatched — see
/// [`SpawnBudget::resolve_against`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SpawnBudget {
    /// Max cumulative input+output tokens for the child conversation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tokens: Option<u64>,
    /// Max cumulative USD spend for the child conversation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub usd: Option<f64>,
    /// Max cumulative LLM iterations (round-trips) for the child conversation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub iterations: Option<u32>,
}

impl SpawnBudget {
    /// Merge this hint against the daemon base caps.
    ///
    /// Each set field replaces the base value for that dimension; unset
    /// fields keep the base. The resulting caps are the ones the spawner
    /// installs via `ConversationBudget::set_conv_config` (which further
    /// clamps to the base so a child cannot raise the ceiling).
    pub fn resolve_against(&self, base: &CostBudgetConfig) -> CostBudgetConfig {
        CostBudgetConfig {
            max_tokens_per_conv: self.tokens.unwrap_or(base.max_tokens_per_conv),
            max_usd_per_conv: self.usd.unwrap_or(base.max_usd_per_conv),
            max_iterations_per_conv: self.iterations.unwrap_or(base.max_iterations_per_conv),
        }
    }

    /// `true` when at least one dimension is set (a real override hint).
    pub fn has_any(&self) -> bool {
        self.tokens.is_some() || self.usd.is_some() || self.iterations.is_some()
    }
}

/// A request to spawn a subagent (design D2/D4/D5/D7).
///
/// Built by the `agent_spawn` tool from the LLM's arguments plus the parent
/// conversation context the tool carries. Pure POD — no daemon dependencies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnSpec {
    /// The task the child agent should accomplish (required). The child's first
    /// user turn.
    pub goal: String,

    /// Optional persona/identity for the child, validated against the identity
    /// loader's known personas. `None` shares the parent's persona.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub persona: Option<String>,

    /// Optional skills to grant the child, validated against known skills.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,

    /// When `true`, [`SubagentSpawner::spawn`] dispatches the child inline and
    /// the returned handle already reflects the completed outcome (the MVP
    /// path, design D3). When `false`, the child runs detached and the parent
    /// retrieves the result later via [`SubagentSpawner::result`].
    #[serde(rename = "await", default)]
    pub await_completion: bool,

    /// Per-child budget overrides (design D5).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub budget: Option<SpawnBudget>,

    /// Swarm grouping id (design D7). Caller-supplied or minted by the first
    /// spawn; `task_status(group_id)` aggregates all siblings.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group_id: Option<String>,

    /// Opt-in proactive-completion flag (design D3.2, deferred/flag-gated). When
    /// set, completion *may* inject a synthetic parent turn if the parent is idle.
    #[serde(default)]
    pub notify_on_complete: bool,

    /// Requested execution backend. Defaults to [`SpawnBackend::Conversation`].
    #[serde(default)]
    pub backend: SpawnBackend,

    /// Spawn depth of this child = parent depth + 1. Refused past the WEFT-180
    /// recursion cap (design D5). The tool fills this from the parent's depth.
    #[serde(default)]
    pub depth: u32,

    /// The parent conversation id (`conv_id = P`). Used for the concurrency
    /// cap, forest edges, and the child conv id (`sub:<P>:<ulid>`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_conv_id: Option<String>,

    /// Universal id of the parent turn that made the spawn call. The
    /// `TriggeredBy` (spawn) and `EvidenceFor` (completion) forest edges link
    /// the child to this turn (design D3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_turn_uid: Option<String>,

    /// The parent agent's principal id, for the spawn-time gate precheck
    /// (design D6). The child then runs under its own principal.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_agent_id: Option<String>,
}

impl SpawnSpec {
    /// Construct a minimal spec for `goal`, with all optional fields defaulted.
    pub fn new(goal: impl Into<String>) -> Self {
        Self {
            goal: goal.into(),
            persona: None,
            skills: Vec::new(),
            await_completion: false,
            budget: None,
            group_id: None,
            notify_on_complete: false,
            backend: SpawnBackend::default(),
            depth: 0,
            parent_conv_id: None,
            parent_turn_uid: None,
            parent_agent_id: None,
        }
    }
}

/// Lifecycle state of a spawned task (design D4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
    /// The child is dispatched and still working.
    Running,
    /// The child finished and produced a result.
    Completed,
    /// The child errored (LLM down, timeout, dispatch failure).
    Failed,
    /// The child hit its budget/iteration cap; a partial result may exist.
    Exhausted,
    /// The child was cancelled (explicit or parent-end cascade).
    Cancelled,
    /// The spawn was refused by governance at the tool boundary.
    Denied,
}

impl TaskStatus {
    /// Whether the task has reached a terminal state (no further transitions).
    pub fn is_terminal(&self) -> bool {
        !matches!(self, Self::Running)
    }

    /// Lowercase wire label (matches the serde representation).
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Running => "running",
            Self::Completed => "completed",
            Self::Failed => "failed",
            Self::Exhausted => "exhausted",
            Self::Cancelled => "cancelled",
            Self::Denied => "denied",
        }
    }
}

/// Handle returned by [`SubagentSpawner::spawn`].
///
/// For `await: true` the `status` already reflects the terminal outcome and
/// `outcome` carries the result; for `await: false` the `status` is
/// [`TaskStatus::Running`] and the result is fetched later.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnHandle {
    /// Stable task identifier (also the `SpawnRegistry` key).
    pub task_id: String,
    /// The child conversation id (`sub:<parent>:<ulid>`).
    pub child_conv_id: String,
    /// Current status of the task.
    pub status: TaskStatus,
    /// Present when the task already reached a terminal state at spawn time
    /// (i.e. `await: true`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome: Option<TaskOutcome>,
}

/// The terminal (or latest) result of a task, returned by
/// [`SubagentSpawner::result`] and [`SubagentSpawner::status`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskOutcome {
    /// The task this outcome belongs to.
    pub task_id: String,
    /// The child conversation id.
    pub child_conv_id: String,
    /// Status at the time of the query.
    pub status: TaskStatus,
    /// The child's final assistant text, if any (may be partial on
    /// [`TaskStatus::Exhausted`]).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<String>,
    /// The child loop's finish reason (`"stop"`, `"max_iterations"`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub finish_reason: Option<String>,
    /// Number of LLM iterations the child consumed.
    #[serde(default)]
    pub iterations: u32,
    /// Optional swarm group id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group_id: Option<String>,
    /// Error detail when `status` is [`TaskStatus::Failed`] /
    /// [`TaskStatus::Denied`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Errors from the spawn seam. Callers distinguish a governance *denial* from a
/// runtime *error* (design D6 / `execute_tool_with_guards`).
#[derive(Debug, Error)]
pub enum SpawnError {
    /// The requested backend is not implemented in this build/phase.
    #[error("spawn backend '{backend}' not available: {reason}")]
    BackendNotAvailable {
        /// Backend kind label (see [`SpawnBackend::kind`]).
        backend: String,
        /// Why it is unavailable.
        reason: String,
    },

    /// The spawn would exceed the recursion depth cap (WEFT-180, design D5).
    #[error("spawn depth {depth} exceeds max {max}")]
    DepthExceeded {
        /// The depth the child would have had.
        depth: u32,
        /// The configured cap.
        max: u32,
    },

    /// The parent conversation already has the maximum live children (design D5).
    #[error("max concurrent subagents ({max}) reached for conversation")]
    ConcurrencyExceeded {
        /// The configured per-conv cap.
        max: u32,
    },

    /// Governance denied the spawn at the tool boundary (design D6). Distinct
    /// from a runtime error so the tool can report `{denied, reason}`.
    #[error("spawn denied by governance: {reason}")]
    Denied {
        /// Human-readable denial reason from the gate.
        reason: String,
    },

    /// No task with the given id is tracked by the registry.
    #[error("task not found: {task_id}")]
    NotFound {
        /// The queried task id.
        task_id: String,
    },

    /// Dispatch into the child conversation failed (LLM down, service gone).
    #[error("subagent dispatch failed: {0}")]
    DispatchFailed(String),

    /// Feature/config disables subagent spawning entirely.
    #[error("subagent spawning is disabled")]
    Disabled,
}

/// The injected seam the `agent_spawn` / `task_status` / `task_result` /
/// `agent_message` tools call into (design D2).
///
/// Concrete impl: `DaemonSubagentSpawner` in `clawft-service-agent`, which
/// dispatches a child conversation through `AgentService::dispatch` (design D1)
/// and draws the `TriggeredBy` / `EvidenceFor` forest edges (design D3).
#[async_trait]
pub trait SubagentSpawner: Send + Sync {
    /// Spawn a subagent per `spec`. For `await: true` the returned handle is
    /// already terminal; for `await: false` it is [`TaskStatus::Running`].
    async fn spawn(&self, spec: SpawnSpec) -> Result<SpawnHandle, SpawnError>;

    /// Current status/outcome of a single task, or `None` if unknown.
    async fn status(&self, task_id: &str) -> Option<TaskOutcome>;

    /// Status of every task in a swarm group (design D7).
    async fn group_status(&self, group_id: &str) -> Vec<TaskOutcome>;

    /// Fetch a task's outcome, optionally blocking up to `wait` for a running
    /// task to finish. On timeout the current (running) outcome is returned.
    async fn result(&self, task_id: &str, wait: Option<Duration>) -> Result<TaskOutcome, SpawnError>;

    /// Inject a message turn into a child conversation (A2A, design D4).
    async fn message(&self, task_id: &str, text: &str) -> Result<(), SpawnError>;

    /// Cancel a running task (explicit or parent-end cascade, design D5).
    async fn cancel(&self, task_id: &str) -> Result<(), SpawnError>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spawn_budget_resolve_against_merges_and_keeps_base() {
        let base = CostBudgetConfig {
            max_tokens_per_conv: 200_000,
            max_usd_per_conv: 1.0,
            max_iterations_per_conv: 30,
        };
        let only_iters = SpawnBudget {
            tokens: None,
            usd: None,
            iterations: Some(3),
        };
        let resolved = only_iters.resolve_against(&base);
        assert_eq!(resolved.max_tokens_per_conv, 200_000);
        assert!((resolved.max_usd_per_conv - 1.0).abs() < 1e-9);
        assert_eq!(resolved.max_iterations_per_conv, 3);
        assert!(only_iters.has_any());
        assert!(!SpawnBudget::default().has_any());
    }

    #[test]
    fn spawn_budget_resolve_tighter_tokens() {
        let base = CostBudgetConfig::default();
        let hint = SpawnBudget {
            tokens: Some(512),
            usd: Some(0.05),
            iterations: Some(2),
        };
        let r = hint.resolve_against(&base);
        assert_eq!(r.max_tokens_per_conv, 512);
        assert!((r.max_usd_per_conv - 0.05).abs() < 1e-9);
        assert_eq!(r.max_iterations_per_conv, 2);
    }
}