1use 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#[derive(Debug, Clone, Copy)]
18pub struct AdapterDescriptor {
19 pub name: &'static str,
21 pub language: &'static str,
23 pub package: &'static str,
25 pub source_path: &'static str,
27 pub sample_path: &'static str,
29 pub docs_path: &'static str,
31}
32
33pub 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#[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 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 pub fn with_traceparent(mut self, traceparent: Option<String>) -> Self {
97 self.traceparent = traceparent;
98 self
99 }
100}
101
102#[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#[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 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 pub fn with_payload(mut self, payload: Value) -> Self {
180 self.payload = Some(payload);
181 self
182 }
183
184 pub fn with_checkpoint(mut self, state: Value) -> Self {
186 self.checkpoint = Some(state);
187 self
188 }
189
190 pub fn with_error(mut self, error: Value) -> Self {
192 self.error = Some(error);
193 self
194 }
195
196 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 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 pub fn with_material_digests(mut self, digests: BTreeMap<String, String>) -> Self {
213 self.material_digests = Some(digests);
214 self
215 }
216
217 pub fn with_policy_decision_id(mut self, decision: Uuid) -> Self {
219 self.policy_decision_id = Some(decision);
220 self
221 }
222
223 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#[async_trait]
250pub trait AgentRunAdapter: Send + Sync {
251 fn name(&self) -> &'static str;
253
254 fn version(&self) -> &'static str;
256
257 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 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 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 async fn restore(&self, _ctx: &AgentRunContext) -> Result<Option<Value>> {
286 Ok(None)
287 }
288
289 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 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}