fleetforge_runtime/
adapters.rs

1//! Adapter scaffolding for bridging authoring frameworks (LangGraph, AutoGen, CrewAI)
2//! into FleetForge's delivery/policy/replay kernel.
3
4use std::collections::{BTreeMap, HashMap};
5
6use anyhow::{anyhow, Result};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use uuid::Uuid;
12
13use crate::{validate_agent_run, RunId, SchemaValidationError, StepId};
14use fleetforge_trust::CapabilityToken;
15
16/// Describes a supported adapter implementation and its discoverability metadata.
17#[derive(Debug, Clone, Copy)]
18pub struct AdapterDescriptor {
19    /// Adapter identifier used in envelopes.
20    pub name: &'static str,
21    /// Reference language/ecosystem for the adapter.
22    pub language: &'static str,
23    /// Package import path (PyPI / npm / etc.).
24    pub package: &'static str,
25    /// Repository path containing the implementation.
26    pub source_path: &'static str,
27    /// Repository path for an executable sample.
28    pub sample_path: &'static str,
29    /// Link to the corresponding how-to guide.
30    pub docs_path: &'static str,
31}
32
33/// Ordered registry of officially supported adapters.
34pub static REGISTERED_ADAPTERS: &[AdapterDescriptor] = &[
35    AdapterDescriptor {
36        name: "langgraph",
37        language: "Python",
38        package: "fleetforge.langgraph_adapter",
39        source_path: "sdk/python/fleetforge/langgraph_adapter",
40        sample_path: "examples/adapters/langgraph",
41        docs_path: "docs/how-to/add-langgraph-adapter.md",
42    },
43    AdapterDescriptor {
44        name: "autogen",
45        language: "Python",
46        package: "fleetforge.autogen_adapter",
47        source_path: "sdk/python/fleetforge/autogen_adapter",
48        sample_path: "examples/adapters/autogen",
49        docs_path: "docs/how-to/add-autogen-adapter.md",
50    },
51    AdapterDescriptor {
52        name: "crewai",
53        language: "Python",
54        package: "fleetforge.crew_adapter",
55        source_path: "sdk/python/fleetforge/crew_adapter",
56        sample_path: "examples/adapters/crewai",
57        docs_path: "docs/how-to/add-crewai-adapter.md",
58    },
59];
60
61/// Execution context shared with adapter hooks.
62#[derive(Debug, Clone)]
63pub struct AgentRunContext {
64    pub run_id: RunId,
65    pub step_id: StepId,
66    pub attempt: i32,
67    pub seed: i64,
68    pub labels: HashMap<String, String>,
69    pub traceparent: Option<String>,
70    pub policy_decision_id: Option<Uuid>,
71    pub capability_token: Option<CapabilityToken>,
72}
73
74impl AgentRunContext {
75    /// Convenience constructor for adapter authors.
76    pub fn new(
77        run_id: RunId,
78        step_id: StepId,
79        attempt: i32,
80        seed: i64,
81        labels: HashMap<String, String>,
82    ) -> Self {
83        Self {
84            run_id,
85            step_id,
86            attempt,
87            seed,
88            labels,
89            traceparent: None,
90            policy_decision_id: None,
91            capability_token: None,
92        }
93    }
94
95    /// Returns a context with the provided W3C traceparent header value.
96    pub fn with_traceparent(mut self, traceparent: Option<String>) -> Self {
97        self.traceparent = traceparent;
98        self
99    }
100}
101
102/// Enumerates the canonical adapter actions mirrored into FleetForge.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum AgentRunAction {
106    Start,
107    Emit,
108    Checkpoint,
109    Restore,
110    Complete,
111    Error,
112}
113
114/// Structured envelope that adapters emit to the runtime.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AgentRunEnvelope {
117    pub adapter: String,
118    pub version: String,
119    pub action: AgentRunAction,
120    pub run_id: RunId,
121    pub step_id: StepId,
122    pub attempt: i32,
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub attestation_ids: Vec<Uuid>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub subject_id: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub material_digests: Option<BTreeMap<String, String>>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub policy_decision_id: Option<Uuid>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub payload: Option<Value>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub checkpoint: Option<Value>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub error: Option<Value>,
137    pub timestamp: DateTime<Utc>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub traceparent: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub capability_token: Option<CapabilityToken>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub capability_token_id: Option<String>,
144}
145
146impl AgentRunEnvelope {
147    /// Creates a baseline envelope for the supplied action.
148    pub fn new(
149        adapter: impl Into<String>,
150        version: impl Into<String>,
151        action: AgentRunAction,
152        ctx: &AgentRunContext,
153    ) -> Self {
154        Self {
155            adapter: adapter.into(),
156            version: version.into(),
157            action,
158            run_id: ctx.run_id,
159            step_id: ctx.step_id,
160            attempt: ctx.attempt,
161            attestation_ids: Vec::new(),
162            subject_id: None,
163            material_digests: None,
164            policy_decision_id: ctx.policy_decision_id,
165            payload: None,
166            checkpoint: None,
167            error: None,
168            timestamp: Utc::now(),
169            traceparent: ctx.traceparent.clone(),
170            capability_token: ctx.capability_token.clone(),
171            capability_token_id: ctx
172                .capability_token
173                .as_ref()
174                .map(|token| token.token_id().to_string()),
175        }
176    }
177
178    /// Attaches a payload (for `emit` actions).
179    pub fn with_payload(mut self, payload: Value) -> Self {
180        self.payload = Some(payload);
181        self
182    }
183
184    /// Attaches a checkpoint snapshot (for `checkpoint` actions).
185    pub fn with_checkpoint(mut self, state: Value) -> Self {
186        self.checkpoint = Some(state);
187        self
188    }
189
190    /// Attaches error metadata (for `error` actions).
191    pub fn with_error(mut self, error: Value) -> Self {
192        self.error = Some(error);
193        self
194    }
195
196    /// Registers attestation identifiers associated with this envelope.
197    pub fn with_attestation_ids<I>(mut self, ids: I) -> Self
198    where
199        I: IntoIterator<Item = Uuid>,
200    {
201        self.attestation_ids = ids.into_iter().collect();
202        self
203    }
204
205    /// Sets the trust subject identifier (run/step/adapter specific).
206    pub fn with_subject_id<S: Into<String>>(mut self, subject_id: S) -> Self {
207        self.subject_id = Some(subject_id.into());
208        self
209    }
210
211    /// Attaches material digests used to derive this step.
212    pub fn with_material_digests(mut self, digests: BTreeMap<String, String>) -> Self {
213        self.material_digests = Some(digests);
214        self
215    }
216
217    /// Associates a policy decision identifier with the envelope.
218    pub fn with_policy_decision_id(mut self, decision: Uuid) -> Self {
219        self.policy_decision_id = Some(decision);
220        self
221    }
222
223    /// Validates the envelope against the embedded AgentRun JSON schema.
224    pub fn validate(&self) -> Result<(), SchemaValidationError> {
225        let value = serde_json::to_value(self).expect("AgentRunEnvelope must serialise to JSON");
226        validate_agent_run(&value)
227    }
228}
229
230fn ensure_valid(envelope: AgentRunEnvelope) -> Result<AgentRunEnvelope> {
231    if let Err(err) = envelope.validate() {
232        let detail = if err.details.is_empty() {
233            err.schema.to_string()
234        } else {
235            err.details
236                .iter()
237                .map(|d| d.to_string())
238                .collect::<Vec<_>>()
239                .join("; ")
240        };
241        return Err(anyhow!(
242            "agent run envelope failed schema validation: {detail}"
243        ));
244    }
245    Ok(envelope)
246}
247
248/// Contract adapters must implement to surface framework-native runs as FleetForge runs.
249#[async_trait]
250pub trait AgentRunAdapter: Send + Sync {
251    /// Adapter identifier (e.g. `langgraph`).
252    fn name(&self) -> &'static str;
253
254    /// SemVer-ish adapter version string.
255    fn version(&self) -> &'static str;
256
257    /// Called before the framework starts producing steps for a run.
258    async fn start(&self, ctx: &AgentRunContext) -> Result<AgentRunEnvelope> {
259        ensure_valid(AgentRunEnvelope::new(
260            self.name(),
261            self.version(),
262            AgentRunAction::Start,
263            ctx,
264        ))
265    }
266
267    /// Called for intermediate output/state events emitted by the framework.
268    async fn emit(&self, ctx: &AgentRunContext, payload: Value) -> Result<AgentRunEnvelope> {
269        ensure_valid(
270            AgentRunEnvelope::new(self.name(), self.version(), AgentRunAction::Emit, ctx)
271                .with_payload(payload),
272        )
273    }
274
275    /// Called whenever the framework checkpoints durable state.
276    async fn checkpoint(&self, ctx: &AgentRunContext, state: Value) -> Result<AgentRunEnvelope> {
277        ensure_valid(
278            AgentRunEnvelope::new(self.name(), self.version(), AgentRunAction::Checkpoint, ctx)
279                .with_checkpoint(state),
280        )
281    }
282
283    /// Called when FleetForge requests a restore. Return `Ok(Some(state))` with the
284    /// previously persisted checkpoint payload if available.
285    async fn restore(&self, _ctx: &AgentRunContext) -> Result<Option<Value>> {
286        Ok(None)
287    }
288
289    /// Called once the framework considers the run complete.
290    async fn complete(&self, ctx: &AgentRunContext) -> Result<AgentRunEnvelope> {
291        ensure_valid(AgentRunEnvelope::new(
292            self.name(),
293            self.version(),
294            AgentRunAction::Complete,
295            ctx,
296        ))
297    }
298
299    /// Called when the framework surfaces a terminal error.
300    async fn error(&self, ctx: &AgentRunContext, error: Value) -> Result<AgentRunEnvelope> {
301        ensure_valid(
302            AgentRunEnvelope::new(self.name(), self.version(), AgentRunAction::Error, ctx)
303                .with_error(error),
304        )
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312    use std::collections::HashMap;
313    use uuid::Uuid;
314
315    struct NoopAdapter;
316
317    #[async_trait]
318    impl AgentRunAdapter for NoopAdapter {
319        fn name(&self) -> &'static str {
320            "noop"
321        }
322
323        fn version(&self) -> &'static str {
324            "0.1.0"
325        }
326    }
327
328    #[tokio::test]
329    async fn adapter_defaults_emit_valid_envelope() {
330        let ctx = AgentRunContext {
331            run_id: RunId(Uuid::new_v4()),
332            step_id: StepId(Uuid::new_v4()),
333            attempt: 1,
334            seed: 123,
335            labels: HashMap::new(),
336            traceparent: Some(
337                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
338            ),
339            policy_decision_id: None,
340            capability_token: None,
341        };
342        let payload = json!({"message": "hello"});
343        let envelope = NoopAdapter
344            .emit(&ctx, payload.clone())
345            .await
346            .expect("emit must succeed");
347        assert_eq!(envelope.action, AgentRunAction::Emit);
348        assert_eq!(envelope.payload, Some(payload));
349        envelope.validate().expect("envelope should validate");
350    }
351
352    #[tokio::test]
353    async fn adapter_error_attaches_payload() {
354        let ctx = AgentRunContext {
355            run_id: RunId(Uuid::new_v4()),
356            step_id: StepId(Uuid::new_v4()),
357            attempt: 1,
358            seed: 99,
359            labels: HashMap::new(),
360            traceparent: None,
361            policy_decision_id: None,
362            capability_token: None,
363        };
364        let err = json!({"kind": "runtime", "message": "boom"});
365        let envelope = NoopAdapter
366            .error(&ctx, err.clone())
367            .await
368            .expect("error must succeed");
369        assert_eq!(envelope.action, AgentRunAction::Error);
370        assert_eq!(envelope.error, Some(err));
371        envelope.validate().expect("envelope should validate");
372    }
373}