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    /// Structured failure details when the owning binding has a closed error
180    /// contract. Older producers omit this field.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub error_details: Option<Value>,
183    pub executor: Option<ToolExecutor>,
184    pub duration_ms: Option<u64>,
185    pub execution_duration_ms: Option<u64>,
186    pub attempt: u32,
187    pub retry_attempts: u32,
188    pub retry_errors: Vec<String>,
189    pub retry_delays_ms: Vec<u64>,
190}
191
192impl Default for CompositionChildResult {
193    fn default() -> Self {
194        Self {
195            run_id: String::new(),
196            tool_call_id: String::new(),
197            tool_name: String::new(),
198            operation_index: 0,
199            status: ToolCallStatus::Pending,
200            raw_output: None,
201            error: None,
202            error_category: None,
203            error_details: None,
204            executor: None,
205            duration_ms: None,
206            execution_duration_ms: None,
207            attempt: 1,
208            retry_attempts: 0,
209            retry_errors: Vec::new(),
210            retry_delays_ms: Vec::new(),
211        }
212    }
213}
214
215#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
216#[serde(default)]
217pub struct CompositionExecutionLimits {
218    pub max_operations: u64,
219    pub timeout_ms: Option<u64>,
220    pub max_output_bytes: u64,
221    pub max_concurrent_operations: usize,
222    pub max_concurrent_per_server: usize,
223}
224
225impl Default for CompositionExecutionLimits {
226    fn default() -> Self {
227        Self {
228            max_operations: 64,
229            timeout_ms: Some(10_000),
230            max_output_bytes: 64 * 1024,
231            max_concurrent_operations: 16,
232            max_concurrent_per_server: 4,
233        }
234    }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
238#[serde(default)]
239pub struct CompositionRetryPolicy {
240    pub max_attempts: u32,
241    pub base_delay_ms: u64,
242    pub max_delay_ms: u64,
243    pub honor_retry_after: bool,
244}
245
246impl Default for CompositionRetryPolicy {
247    fn default() -> Self {
248        Self {
249            max_attempts: 3,
250            base_delay_ms: 100,
251            max_delay_ms: 2_000,
252            honor_retry_after: true,
253        }
254    }
255}
256
257#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
258#[serde(default)]
259pub struct CompositionMcpPolicy {
260    pub trusted_servers: BTreeSet<String>,
261    pub trust_annotations: bool,
262    pub retry: CompositionRetryPolicy,
263    pub call_timeout_ms: Option<u64>,
264}
265
266#[derive(Clone, Debug, Serialize, Deserialize)]
267#[serde(default)]
268pub struct CompositionExecutionRequest {
269    pub session_id: Option<String>,
270    pub run_id: String,
271    pub language: String,
272    pub snippet: String,
273    pub manifest: BindingManifest,
274    pub requested_side_effect_ceiling: SideEffectLevel,
275    pub limits: CompositionExecutionLimits,
276    pub mcp_policy: CompositionMcpPolicy,
277    pub metadata: Value,
278}
279
280impl Default for CompositionExecutionRequest {
281    fn default() -> Self {
282        Self {
283            session_id: None,
284            run_id: String::new(),
285            language: "harn".to_string(),
286            snippet: String::new(),
287            manifest: BindingManifest::default(),
288            requested_side_effect_ceiling: SideEffectLevel::ReadOnly,
289            limits: CompositionExecutionLimits::default(),
290            mcp_policy: CompositionMcpPolicy::default(),
291            metadata: Value::Object(serde_json::Map::new()),
292        }
293    }
294}
295
296#[derive(Clone, Debug, Serialize, Deserialize)]
297pub struct CompositionExecutionReport {
298    pub schema_version: u32,
299    pub ok: bool,
300    pub run: CompositionRunEnvelope,
301    pub child_calls: Vec<CompositionChildCall>,
302    pub child_results: Vec<CompositionChildResult>,
303    pub summary: String,
304}
305
306#[derive(Clone, Debug, Serialize, Deserialize)]
307pub struct CompositionToolOutput {
308    pub value: Option<Value>,
309    pub error: Option<String>,
310    pub error_category: Option<ToolCallErrorCategory>,
311    pub executor: Option<ToolExecutor>,
312}
313
314impl CompositionToolOutput {
315    pub fn ok(value: Value) -> Self {
316        Self {
317            value: Some(value),
318            error: None,
319            error_category: None,
320            executor: Some(ToolExecutor::HarnBuiltin),
321        }
322    }
323
324    pub fn error(message: impl Into<String>, category: ToolCallErrorCategory) -> Self {
325        Self {
326            value: None,
327            error: Some(message.into()),
328            error_category: Some(category),
329            executor: Some(ToolExecutor::HarnBuiltin),
330        }
331    }
332}
333
334#[async_trait::async_trait]
335pub trait CompositionToolHost: Send + Sync {
336    async fn call(&self, binding: &BindingManifestEntry, input: Value) -> CompositionToolOutput;
337}