Skip to main content

llm_tool_runtime/
contracts.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use stack_ids::{
5    ApplicabilityContextId, ApprovalGrantId, ApprovalRecordId, ArtifactId, AttemptId,
6    AttestationEnvelopeId, CompiledObligationSetId, CompositionReceiptId, ContentDigest,
7    CrossRuntimeReplayTicketId, EffectCommitDecisionId, EffectExecutionReceiptId,
8    EffectiveConstitutionId, ExecutionPermitId, PolicyDecisionId, ProfileSetId,
9    RemoteOracleLeaseId, RemoteSliceResultId, ScopeKey, ToolEffectDispatchReceiptId, TraceCtx,
10    TrialId,
11};
12use std::fmt;
13use thiserror::Error;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ToolExecutionPermitScope {
17    namespace: String,
18    target_key: String,
19}
20
21impl ToolExecutionPermitScope {
22    /// Returns the namespace bound by this execution permit.
23    pub fn namespace(&self) -> &str {
24        &self.namespace
25    }
26
27    /// Returns the concrete target key bound by this execution permit.
28    pub fn target_key(&self) -> &str {
29        &self.target_key
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ToolExecutionPermit {
35    execution_permit_id: ExecutionPermitId,
36    decision_id: PolicyDecisionId,
37    approval_record_id: Option<ApprovalRecordId>,
38    scope: ToolExecutionPermitScope,
39}
40
41impl ToolExecutionPermit {
42    /// Builds a runtime execution permit snapshot for effectful tool dispatch.
43    pub fn new(
44        execution_permit_id: ExecutionPermitId,
45        decision_id: PolicyDecisionId,
46        approval_record_id: Option<ApprovalRecordId>,
47        namespace: impl Into<String>,
48        target_key: impl Into<String>,
49    ) -> Self {
50        Self {
51            execution_permit_id,
52            decision_id,
53            approval_record_id,
54            scope: ToolExecutionPermitScope {
55                namespace: namespace.into(),
56                target_key: target_key.into(),
57            },
58        }
59    }
60
61    /// Returns the execution permit identifier.
62    pub fn execution_permit_id(&self) -> &ExecutionPermitId {
63        &self.execution_permit_id
64    }
65
66    /// Returns the policy decision lineage bound to this permit.
67    pub fn decision_id(&self) -> &PolicyDecisionId {
68        &self.decision_id
69    }
70
71    /// Returns the optional approval-record lineage bound to this permit.
72    pub fn approval_record_id(&self) -> Option<&ApprovalRecordId> {
73        self.approval_record_id.as_ref()
74    }
75
76    /// Returns the namespace/target scope enforced by this permit.
77    pub fn scope(&self) -> &ToolExecutionPermitScope {
78        &self.scope
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ToolBackendKind {
85    LocalFunction,
86    OpenAiFunction,
87    OpenAiBuiltIn,
88    OllamaFunction,
89    RemoteMcp,
90}
91
92impl ToolBackendKind {
93    /// Returns the stable snake_case label used in serialized receipts.
94    pub fn as_str(&self) -> &'static str {
95        match self {
96            Self::LocalFunction => "local_function",
97            Self::OpenAiFunction => "open_ai_function",
98            Self::OpenAiBuiltIn => "open_ai_built_in",
99            Self::OllamaFunction => "ollama_function",
100            Self::RemoteMcp => "remote_mcp",
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum ToolOutputMode {
108    StructuredJson,
109    Text,
110    ArtifactRefs,
111    JobHandle,
112}
113
114#[derive(
115    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
116)]
117#[serde(rename_all = "snake_case")]
118pub enum ToolSideEffectClass {
119    ReadOnly,
120    Analysis,
121    PreviewWrite,
122    Write,
123    Admin,
124}
125
126impl fmt::Display for ToolSideEffectClass {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(match self {
129            Self::ReadOnly => "read-only",
130            Self::Analysis => "analysis",
131            Self::PreviewWrite => "preview-write",
132            Self::Write => "write",
133            Self::Admin => "admin",
134        })
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum ToolIdempotencyClass {
141    Idempotent,
142    BestEffort,
143    NonIdempotent,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum ToolApprovalKind {
149    None,
150    UserRequired,
151    PolicyRequired,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ToolExposureMode {
157    Auto,
158    OptIn,
159    Hidden,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum McpSurfaceKind {
165    None,
166    Tool,
167    Resource,
168    Prompt,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
172#[serde(rename_all = "snake_case")]
173pub enum ToolReceiptPersistence {
174    #[default]
175    Ephemeral,
176    ForgeRaw,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ToolOriginKind {
182    OpenAiResponses,
183    OpenAiChat,
184    OllamaChat,
185    Local,
186    Mcp,
187    Test,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum ToolPlannerStage {
193    InitialRouting,
194    Retrieval,
195    Analysis,
196    Execution,
197    Audit,
198}
199
200impl ToolPlannerStage {
201    /// Returns the stable snake_case label used in serialized planning metadata.
202    pub fn as_str(&self) -> &'static str {
203        match self {
204            Self::InitialRouting => "initial_routing",
205            Self::Retrieval => "retrieval",
206            Self::Analysis => "analysis",
207            Self::Execution => "execution",
208            Self::Audit => "audit",
209        }
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ToolApprovalState {
216    NotRequired,
217    Approved,
218    Denied,
219}
220
221impl ToolApprovalState {
222    /// Returns the stable snake_case label used in serialized approval metadata.
223    pub fn as_str(&self) -> &'static str {
224        match self {
225            Self::NotRequired => "not_required",
226            Self::Approved => "approved",
227            Self::Denied => "denied",
228        }
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum ToolRetryOwner {
235    LlmPipeline,
236    AgentGraph,
237    JobQueue,
238    AiBatchQueue,
239    ForgeOrchestration,
240    External,
241}
242
243impl ToolRetryOwner {
244    /// Returns the stable snake_case label used in serialized retry-owner metadata.
245    pub fn as_str(&self) -> &'static str {
246        match self {
247            Self::LlmPipeline => "llm_pipeline",
248            Self::AgentGraph => "agent_graph",
249            Self::JobQueue => "job_queue",
250            Self::AiBatchQueue => "ai_batch_queue",
251            Self::ForgeOrchestration => "forge_orchestration",
252            Self::External => "external",
253        }
254    }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum ApprovalGrantEffectClass {
260    Analysis,
261    PreviewWrite,
262    Write,
263    Admin,
264}
265
266impl ApprovalGrantEffectClass {
267    /// Maps a tool-side effect class into the approval-grant vocabulary.
268    pub fn for_tool_side_effect_class(side_effect_class: &ToolSideEffectClass) -> Option<Self> {
269        match side_effect_class {
270            ToolSideEffectClass::ReadOnly => None,
271            ToolSideEffectClass::Analysis => Some(Self::Analysis),
272            ToolSideEffectClass::PreviewWrite => Some(Self::PreviewWrite),
273            ToolSideEffectClass::Write => Some(Self::Write),
274            ToolSideEffectClass::Admin => Some(Self::Admin),
275        }
276    }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ApprovalGrantCitation {
281    pub applicability_context_id: ApplicabilityContextId,
282    pub profile_set_id: ProfileSetId,
283    pub composition_receipt_id: CompositionReceiptId,
284    pub effective_constitution_id: EffectiveConstitutionId,
285    pub compiled_obligation_set_id: CompiledObligationSetId,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct ApprovalGrantScope {
290    pub namespace: String,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub target_key: Option<String>,
293    pub tool_name: String,
294    pub effect_class: ApprovalGrantEffectClass,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub planner_stage: Option<ToolPlannerStage>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ApprovalGrant {
301    pub grant_id: ApprovalGrantId,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub approval_record_id: Option<ApprovalRecordId>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub decision_id: Option<PolicyDecisionId>,
306    pub policy_version: String,
307    pub scope: ApprovalGrantScope,
308    #[serde(default)]
309    pub approver_lineage: Vec<String>,
310    pub approved_at: String,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub expires_at: Option<String>,
313    pub citation: ApprovalGrantCitation,
314}
315
316impl ApprovalGrant {
317    /// Builds a typed approval grant for one tool surface.
318    pub fn new(
319        policy_version: impl Into<String>,
320        scope: ApprovalGrantScope,
321        approver_lineage: Vec<String>,
322        approved_at: impl Into<String>,
323        expires_at: Option<String>,
324        citation: ApprovalGrantCitation,
325    ) -> Self {
326        Self {
327            grant_id: ApprovalGrantId::generate(),
328            approval_record_id: None,
329            decision_id: None,
330            policy_version: policy_version.into(),
331            scope,
332            approver_lineage,
333            approved_at: approved_at.into(),
334            expires_at,
335            citation,
336        }
337    }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum ToolErrorClass {
343    InvalidArguments,
344    UnknownTool,
345    ApprovalRequired,
346    Denied,
347    Timeout,
348    Cancelled,
349    OutputTooLarge,
350    Execution,
351    ReceiptPersistence,
352    ProviderContract,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ToolExposurePolicy {
357    pub exposure_mode: ToolExposureMode,
358    #[serde(default)]
359    pub allow_parallel_calls: bool,
360    #[serde(default)]
361    pub max_calls_per_turn: u32,
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub allowed_planner_stages: Vec<ToolPlannerStage>,
364}
365
366impl Default for ToolExposurePolicy {
367    fn default() -> Self {
368        Self {
369            exposure_mode: ToolExposureMode::Auto,
370            allow_parallel_calls: false,
371            max_calls_per_turn: 1,
372            allowed_planner_stages: Vec::new(),
373        }
374    }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct ToolDescriptor {
379    pub name: String,
380    pub version: String,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub description: Option<String>,
383    pub backend_kind: ToolBackendKind,
384    pub input_schema: Value,
385    pub output_mode: ToolOutputMode,
386    pub read_only: bool,
387    pub side_effect_class: ToolSideEffectClass,
388    pub idempotency_class: ToolIdempotencyClass,
389    pub approval_kind: ToolApprovalKind,
390    pub timeout_ms: u64,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub concurrency_key: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub cache_ttl_ms: Option<u64>,
395    pub exposure_mode: ToolExposureMode,
396    pub mcp_surface_kind: McpSurfaceKind,
397    #[serde(default)]
398    pub exposure_policy: ToolExposurePolicy,
399    #[serde(default)]
400    pub receipt_persistence: ToolReceiptPersistence,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub output_size_limit_bytes: Option<usize>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub provider_payload: Option<Value>,
405}
406
407impl ToolDescriptor {
408    /// Returns true when this descriptor can be dispatched through a local function bridge.
409    pub fn supports_local_dispatch(&self) -> bool {
410        matches!(
411            self.backend_kind,
412            ToolBackendKind::LocalFunction
413                | ToolBackendKind::OpenAiFunction
414                | ToolBackendKind::OllamaFunction
415        )
416    }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct ToolBudgetContext {
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub budget_kind: Option<String>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub max_steps: Option<u32>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub time_budget_ms: Option<u64>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub cost_budget_units: Option<u64>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct ToolCtx {
433    pub trace_ctx: TraceCtx,
434    pub attempt_id: AttemptId,
435    pub trial_id: TrialId,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub deadline: Option<String>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub workload_class: Option<String>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub budget_context: Option<ToolBudgetContext>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub scope: Option<ScopeKey>,
444    #[serde(default)]
445    pub dry_run: bool,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub approval_grant: Option<ApprovalGrant>,
448    #[serde(default, skip_serializing, skip_deserializing)]
449    pub execution_permit: Option<ToolExecutionPermit>,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub idempotency_key: Option<String>,
452    pub caller: String,
453    pub planner_stage: ToolPlannerStage,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub parent_receipt_id: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub family_receipt_id: Option<String>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub replay_parent_receipt_id: Option<String>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub remote_oracle_lease_id: Option<RemoteOracleLeaseId>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub remote_slice_result_id: Option<RemoteSliceResultId>,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub attestation_envelope_id: Option<AttestationEnvelopeId>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub cross_runtime_replay_ticket_id: Option<CrossRuntimeReplayTicketId>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub retry_owner: Option<ToolRetryOwner>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct ToolCall {
474    pub descriptor_name: String,
475    pub descriptor_version: String,
476    pub arguments: Value,
477    pub origin_kind: ToolOriginKind,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub provider_call_id: Option<String>,
480    pub tool_run_id: String,
481}
482
483impl ToolCall {
484    /// Creates a tool call payload with a fresh tool-run identifier.
485    pub fn new(
486        descriptor_name: impl Into<String>,
487        descriptor_version: impl Into<String>,
488        arguments: Value,
489        origin_kind: ToolOriginKind,
490    ) -> Self {
491        Self {
492            descriptor_name: descriptor_name.into(),
493            descriptor_version: descriptor_version.into(),
494            arguments,
495            origin_kind,
496            provider_call_id: None,
497            tool_run_id: uuid::Uuid::new_v4().to_string(),
498        }
499    }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct ToolArtifactRef {
504    pub artifact_id: ArtifactId,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub label: Option<String>,
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub mime_type: Option<String>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct ToolJobHandle {
513    pub job_id: String,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub status: Option<String>,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct ToolResult {
520    pub mode: ToolOutputMode,
521    pub payload: Value,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub display_text: Option<String>,
524}
525
526impl ToolResult {
527    /// Builds a structured-JSON tool result.
528    pub fn json(payload: Value) -> Self {
529        Self {
530            mode: ToolOutputMode::StructuredJson,
531            payload,
532            display_text: None,
533        }
534    }
535
536    /// Builds a text-mode tool result.
537    pub fn text(text: impl Into<String>) -> Self {
538        let text = text.into();
539        Self {
540            mode: ToolOutputMode::Text,
541            payload: Value::String(text.clone()),
542            display_text: Some(text),
543        }
544    }
545
546    /// Builds an artifact-reference tool result.
547    pub fn artifact_refs(refs: Vec<ToolArtifactRef>) -> Self {
548        Self {
549            mode: ToolOutputMode::ArtifactRefs,
550            payload: serde_json::to_value(refs).unwrap_or(Value::Array(Vec::new())),
551            display_text: None,
552        }
553    }
554
555    /// Builds a job-handle tool result.
556    pub fn job_handle(handle: ToolJobHandle) -> Self {
557        Self {
558            mode: ToolOutputMode::JobHandle,
559            payload: serde_json::to_value(handle).unwrap_or(Value::Null),
560            display_text: None,
561        }
562    }
563
564    /// Renders the result into the string form exposed back to the model.
565    pub fn to_model_output(&self) -> String {
566        match self.mode {
567            ToolOutputMode::Text => self
568                .display_text
569                .clone()
570                .unwrap_or_else(|| self.payload.as_str().unwrap_or_default().to_string()),
571            ToolOutputMode::StructuredJson
572            | ToolOutputMode::ArtifactRefs
573            | ToolOutputMode::JobHandle => serde_json::to_string(&self.payload)
574                .unwrap_or_else(|_| "\"<tool_result_unserializable>\"".to_string()),
575        }
576    }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, Error)]
580#[error("{class:?}: {message}")]
581pub struct ToolError {
582    pub class: ToolErrorClass,
583    pub message: String,
584    #[serde(default)]
585    pub retryable: bool,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub details: Option<Value>,
588}
589
590impl ToolError {
591    /// Creates a non-retryable tool error with the supplied class and message.
592    pub fn new(class: ToolErrorClass, message: impl Into<String>) -> Self {
593        Self {
594            class,
595            message: message.into(),
596            retryable: false,
597            details: None,
598        }
599    }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct ToolReceipt {
604    pub receipt_id: String,
605    pub tool_name: String,
606    pub tool_version: String,
607    pub backend_kind: ToolBackendKind,
608    pub input_digest: ContentDigest,
609    pub output_digest_or_refs: Value,
610    pub policy_hash: ContentDigest,
611    pub approval_state: ToolApprovalState,
612    pub host_identity: String,
613    pub started_at: String,
614    pub finished_at: String,
615    pub trace_ctx: TraceCtx,
616    pub attempt_id: AttemptId,
617    pub trial_id: TrialId,
618    pub planner_stage: ToolPlannerStage,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub deadline: Option<String>,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub workload_class: Option<String>,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub budget_context: Option<ToolBudgetContext>,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub parent_receipt_id: Option<String>,
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub family_receipt_id: Option<String>,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub replay_parent_receipt_id: Option<String>,
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub remote_oracle_lease_id: Option<RemoteOracleLeaseId>,
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub remote_slice_result_id: Option<RemoteSliceResultId>,
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub attestation_envelope_id: Option<AttestationEnvelopeId>,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub cross_runtime_replay_ticket_id: Option<CrossRuntimeReplayTicketId>,
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub error_class: Option<ToolErrorClass>,
641    pub retry_owner: ToolRetryOwner,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub replay_link: Option<String>,
644    pub tool_run_id: String,
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub provider_call_id: Option<String>,
647}
648
649impl ToolReceipt {
650    /// Normalizes a runtime-native tool receipt into the canonical Forge receipt schema.
651    pub fn to_forge_tool_receipt_v2(
652        &self,
653        raw_payload: Value,
654    ) -> semantic_memory_forge::ForgeToolReceiptV2 {
655        semantic_memory_forge::ForgeToolReceiptV2 {
656            schema_version: semantic_memory_forge::FORGE_TOOL_RECEIPT_V2_SCHEMA.into(),
657            receipt_id: self.receipt_id.clone(),
658            tool_run_id: self.tool_run_id.clone(),
659            tool_name: self.tool_name.clone(),
660            tool_version: self.tool_version.clone(),
661            backend_kind: self.backend_kind.as_str().into(),
662            input_digest: self.input_digest.clone(),
663            output_digest_or_refs: self.output_digest_or_refs.clone(),
664            policy_hash: self.policy_hash.clone(),
665            approval_state: self.approval_state.as_str().into(),
666            host_identity: self.host_identity.clone(),
667            started_at: self.started_at.clone(),
668            finished_at: self.finished_at.clone(),
669            trace_ctx: self.trace_ctx.clone(),
670            attempt_id: self.attempt_id.clone(),
671            trial_id: self.trial_id.clone(),
672            planner_stage: self.planner_stage.as_str().into(),
673            deadline: self.deadline.clone(),
674            workload_class: self.workload_class.clone(),
675            budget_context: self.budget_context.as_ref().map(|budget| {
676                semantic_memory_forge::ForgeToolBudgetContext {
677                    budget_kind: budget.budget_kind.clone(),
678                    max_steps: budget.max_steps,
679                    time_budget_ms: budget.time_budget_ms,
680                    cost_budget_units: budget.cost_budget_units,
681                }
682            }),
683            parent_receipt_id: self.parent_receipt_id.clone(),
684            family_receipt_id: self.family_receipt_id.clone(),
685            replay_parent_receipt_id: self.replay_parent_receipt_id.clone(),
686            error_class: self.error_class.as_ref().map(|class| format!("{class:?}")),
687            retry_owner: self.retry_owner.as_str().into(),
688            replay_link: self.replay_link.clone(),
689            provider_call_id: self.provider_call_id.clone(),
690            raw_payload,
691        }
692    }
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct ToolEffectDispatchReceiptV1 {
697    pub schema_version: String,
698    pub tool_effect_dispatch_receipt_id: ToolEffectDispatchReceiptId,
699    pub effect_commit_decision_id: EffectCommitDecisionId,
700    pub tool_receipt_id: String,
701    pub provider_route: Vec<String>,
702    pub dispatch_state: String,
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub effect_execution_receipt_id: Option<EffectExecutionReceiptId>,
705    pub deadline_at_dispatch: String,
706    pub cancellation_reason: String,
707}