Skip to main content

harn_vm/composition/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeSet;
4
5use crate::agent_events::{ToolCallErrorCategory, ToolCallStatus, ToolExecutor};
6use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
7
8use super::manifest::{BindingManifest, BindingManifestEntry};
9
10pub const COMPOSITION_EXECUTION_SCHEMA_VERSION: u32 = 1;
11
12/// Stable failure taxonomy for a composition run. Tool-level failures stay on
13/// [`CompositionChildResult`]; this classifies why the parent composition
14/// itself failed or stopped.
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CompositionFailureCategory {
18    /// The snippet language is unknown or not enabled by the current host.
19    UnsupportedLanguage,
20    /// The snippet or manifest did not validate before execution.
21    SchemaValidation,
22    /// Capability policy rejected the requested side-effect ceiling or a child
23    /// operation.
24    PolicyDenied,
25    /// A child binding returned an error.
26    ChildToolError,
27    /// The executor failed before it could attribute the error to a child call.
28    ExecutionError,
29    /// The run exceeded its time or step budget.
30    Timeout,
31    /// The host or caller cancelled the run.
32    Cancelled,
33    /// Fallback when a producer cannot classify the failure.
34    Unknown,
35}
36
37impl CompositionFailureCategory {
38    pub const ALL: [Self; 8] = [
39        Self::UnsupportedLanguage,
40        Self::SchemaValidation,
41        Self::PolicyDenied,
42        Self::ChildToolError,
43        Self::ExecutionError,
44        Self::Timeout,
45        Self::Cancelled,
46        Self::Unknown,
47    ];
48
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::UnsupportedLanguage => "unsupported_language",
52            Self::SchemaValidation => "schema_validation",
53            Self::PolicyDenied => "policy_denied",
54            Self::ChildToolError => "child_tool_error",
55            Self::ExecutionError => "execution_error",
56            Self::Timeout => "timeout",
57            Self::Cancelled => "cancelled",
58            Self::Unknown => "unknown",
59        }
60    }
61}
62
63/// Identity and policy envelope for one composition run.
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(default)]
66pub struct CompositionRunEnvelope {
67    /// Runtime-unique id used to correlate child calls and terminal events.
68    pub run_id: String,
69    /// Snippet frontend (`harn`, `typescript`, `javascript`, ...).
70    pub language: String,
71    /// `sha256:<hex>` digest over the language and snippet bytes.
72    pub snippet_hash: String,
73    /// `sha256:<hex>` digest over the binding manifest shown to the model.
74    pub binding_manifest_hash: String,
75    /// Highest side-effect level requested by the parent run.
76    pub requested_side_effect_ceiling: SideEffectLevel,
77    /// Captured stdout-like text emitted by the composition executor.
78    pub stdout: Option<String>,
79    /// Captured stderr-like text emitted by the composition executor.
80    pub stderr: Option<String>,
81    /// Artifact descriptors/handles emitted by the composition executor.
82    pub artifacts: Vec<Value>,
83    /// Structured result returned by the snippet.
84    pub result: Option<Value>,
85    /// Parent-run failure class, absent for successful finishes.
86    pub failure_category: Option<CompositionFailureCategory>,
87    /// Human-readable parent-run error, absent for successful finishes.
88    pub error: Option<String>,
89    /// Runtime wall-clock duration when a producer has measured it.
90    pub duration_ms: Option<u64>,
91    /// Forward-compatible producer metadata. Consumers must ignore unknown keys.
92    pub metadata: Value,
93}
94
95impl Default for CompositionRunEnvelope {
96    fn default() -> Self {
97        Self {
98            run_id: String::new(),
99            language: String::new(),
100            snippet_hash: String::new(),
101            binding_manifest_hash: String::new(),
102            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
103            stdout: None,
104            stderr: None,
105            artifacts: Vec::new(),
106            result: None,
107            failure_category: None,
108            error: None,
109            duration_ms: None,
110            metadata: Value::Object(serde_json::Map::new()),
111        }
112    }
113}
114
115impl CompositionRunEnvelope {
116    pub fn read_only(
117        run_id: impl Into<String>,
118        language: impl Into<String>,
119        snippet_hash: impl Into<String>,
120        binding_manifest_hash: impl Into<String>,
121    ) -> Self {
122        Self {
123            run_id: run_id.into(),
124            language: language.into(),
125            snippet_hash: snippet_hash.into(),
126            binding_manifest_hash: binding_manifest_hash.into(),
127            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
128            ..Self::default()
129        }
130    }
131}
132
133/// Child tool call made by a composition snippet. This is intentionally close
134/// to `AgentEvent::ToolCall`, but includes parent-run correlation and the
135/// policy/annotation context the composition executor used when deciding
136/// whether the call was allowed.
137#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
138#[serde(default)]
139pub struct CompositionChildCall {
140    pub run_id: String,
141    pub tool_call_id: String,
142    pub tool_name: String,
143    pub operation_index: u64,
144    pub annotations: Option<ToolAnnotations>,
145    pub requested_side_effect_level: SideEffectLevel,
146    pub policy_context: Value,
147    pub raw_input: Value,
148}
149
150impl Default for CompositionChildCall {
151    fn default() -> Self {
152        Self {
153            run_id: String::new(),
154            tool_call_id: String::new(),
155            tool_name: String::new(),
156            operation_index: 0,
157            annotations: None,
158            requested_side_effect_level: SideEffectLevel::None,
159            policy_context: Value::Object(serde_json::Map::new()),
160            raw_input: Value::Null,
161        }
162    }
163}
164
165/// Terminal or intermediate result for a child binding operation. Consumers
166/// should pair this with the corresponding [`CompositionChildCall`] to recover
167/// the policy annotations and requested side-effect level for the operation.
168#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
169#[serde(default)]
170pub struct CompositionChildResult {
171    pub run_id: String,
172    pub tool_call_id: String,
173    pub tool_name: String,
174    pub operation_index: u64,
175    pub status: ToolCallStatus,
176    pub raw_output: Option<Value>,
177    pub error: Option<String>,
178    pub error_category: Option<ToolCallErrorCategory>,
179    pub executor: Option<ToolExecutor>,
180    pub duration_ms: Option<u64>,
181    pub execution_duration_ms: Option<u64>,
182    pub attempt: u32,
183    pub retry_attempts: u32,
184    pub retry_errors: Vec<String>,
185    pub retry_delays_ms: Vec<u64>,
186}
187
188impl Default for CompositionChildResult {
189    fn default() -> Self {
190        Self {
191            run_id: String::new(),
192            tool_call_id: String::new(),
193            tool_name: String::new(),
194            operation_index: 0,
195            status: ToolCallStatus::Pending,
196            raw_output: None,
197            error: None,
198            error_category: None,
199            executor: None,
200            duration_ms: None,
201            execution_duration_ms: None,
202            attempt: 1,
203            retry_attempts: 0,
204            retry_errors: Vec::new(),
205            retry_delays_ms: Vec::new(),
206        }
207    }
208}
209
210#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
211#[serde(default)]
212pub struct CompositionExecutionLimits {
213    pub max_operations: u64,
214    pub timeout_ms: Option<u64>,
215    pub max_output_bytes: u64,
216    pub max_concurrent_operations: usize,
217    pub max_concurrent_per_server: usize,
218}
219
220impl Default for CompositionExecutionLimits {
221    fn default() -> Self {
222        Self {
223            max_operations: 64,
224            timeout_ms: Some(10_000),
225            max_output_bytes: 64 * 1024,
226            max_concurrent_operations: 16,
227            max_concurrent_per_server: 4,
228        }
229    }
230}
231
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233#[serde(default)]
234pub struct CompositionRetryPolicy {
235    pub max_attempts: u32,
236    pub base_delay_ms: u64,
237    pub max_delay_ms: u64,
238    pub honor_retry_after: bool,
239}
240
241impl Default for CompositionRetryPolicy {
242    fn default() -> Self {
243        Self {
244            max_attempts: 3,
245            base_delay_ms: 100,
246            max_delay_ms: 2_000,
247            honor_retry_after: true,
248        }
249    }
250}
251
252#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
253#[serde(default)]
254pub struct CompositionMcpPolicy {
255    pub trusted_servers: BTreeSet<String>,
256    pub trust_annotations: bool,
257    pub retry: CompositionRetryPolicy,
258    pub call_timeout_ms: Option<u64>,
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
262#[serde(default)]
263pub struct CompositionExecutionRequest {
264    pub session_id: Option<String>,
265    pub run_id: String,
266    pub language: String,
267    pub snippet: String,
268    pub manifest: BindingManifest,
269    pub requested_side_effect_ceiling: SideEffectLevel,
270    pub limits: CompositionExecutionLimits,
271    pub mcp_policy: CompositionMcpPolicy,
272    pub metadata: Value,
273}
274
275impl Default for CompositionExecutionRequest {
276    fn default() -> Self {
277        Self {
278            session_id: None,
279            run_id: String::new(),
280            language: "harn".to_string(),
281            snippet: String::new(),
282            manifest: BindingManifest::default(),
283            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
284            limits: CompositionExecutionLimits::default(),
285            mcp_policy: CompositionMcpPolicy::default(),
286            metadata: Value::Object(serde_json::Map::new()),
287        }
288    }
289}
290
291#[derive(Clone, Debug, Serialize, Deserialize)]
292pub struct CompositionExecutionReport {
293    pub schema_version: u32,
294    pub ok: bool,
295    pub run: CompositionRunEnvelope,
296    pub child_calls: Vec<CompositionChildCall>,
297    pub child_results: Vec<CompositionChildResult>,
298    pub summary: String,
299}
300
301#[derive(Clone, Debug, Serialize, Deserialize)]
302pub struct CompositionToolOutput {
303    pub value: Option<Value>,
304    pub error: Option<String>,
305    pub error_category: Option<ToolCallErrorCategory>,
306    pub executor: Option<ToolExecutor>,
307}
308
309impl CompositionToolOutput {
310    pub fn ok(value: Value) -> Self {
311        Self {
312            value: Some(value),
313            error: None,
314            error_category: None,
315            executor: Some(ToolExecutor::HarnBuiltin),
316        }
317    }
318
319    pub fn error(message: impl Into<String>, category: ToolCallErrorCategory) -> Self {
320        Self {
321            value: None,
322            error: Some(message.into()),
323            error_category: Some(category),
324            executor: Some(ToolExecutor::HarnBuiltin),
325        }
326    }
327}
328
329#[async_trait::async_trait]
330pub trait CompositionToolHost: Send + Sync {
331    async fn call(&self, binding: &BindingManifestEntry, input: Value) -> CompositionToolOutput;
332}