Skip to main content

llm_tool_runtime/
contracts.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use stack_ids::{
7    ApplicabilityContextId, ApprovalGrantId, ApprovalRecordId, ArtifactId, AttemptId,
8    AttestationEnvelopeId, CompiledObligationSetId, CompositionReceiptId, ContentDigest,
9    CrossRuntimeReplayTicketId, DigestBuilder, EffectCommitDecisionId, EffectExecutionReceiptId,
10    EffectiveConstitutionId, ExecutionPermitId, PolicyDecisionId, ProfileSetId,
11    RemoteOracleLeaseId, RemoteSliceResultId, ScopeKey, ToolEffectDispatchReceiptId, TraceCtx,
12    TrialId,
13};
14use std::fmt;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use thiserror::Error;
18
19#[async_trait]
20pub trait CompensatingAction: Send + Sync {
21    async fn compensate(&self, receipt: &ToolReceipt) -> Result<(), ToolError>;
22}
23
24#[derive(Debug)]
25pub struct ControlData<T>(pub(crate) T);
26
27#[derive(Debug, Clone)]
28pub struct UntrustedData<T>(pub T);
29
30/// Accepts only trusted control data.
31///
32/// `UntrustedData` cannot be passed into this function directly.
33///
34/// ```compile_fail
35/// use llm_tool_runtime::{accept_control_data, UntrustedData};
36///
37/// let untrusted = UntrustedData("payload".to_string());
38/// // Type mismatch: this function expects `ControlData`, not `UntrustedData`.
39/// accept_control_data(untrusted);
40/// ```
41pub fn accept_control_data<T>(data: ControlData<T>) -> T {
42    data.0
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ToolExecutionPermitScope {
47    namespace: String,
48    target_key: String,
49}
50
51impl ToolExecutionPermitScope {
52    /// Returns the namespace bound by this execution permit.
53    pub fn namespace(&self) -> &str {
54        &self.namespace
55    }
56
57    /// Returns the concrete target key bound by this execution permit.
58    pub fn target_key(&self) -> &str {
59        &self.target_key
60    }
61}
62
63#[derive(Debug)]
64pub struct ToolExecutionPermit {
65    execution_permit_id: ExecutionPermitId,
66    decision_id: PolicyDecisionId,
67    approval_record_id: Option<ApprovalRecordId>,
68    scope: ToolExecutionPermitScope,
69    expires_at: Option<DateTime<Utc>>,
70    nonce: String,
71    consumed: AtomicBool,
72    method_digest: ContentDigest,
73    effect_digest: ContentDigest,
74}
75
76impl ToolExecutionPermit {
77    /// Builds a runtime execution permit snapshot for effectful tool dispatch.
78    pub fn new(
79        execution_permit_id: ExecutionPermitId,
80        decision_id: PolicyDecisionId,
81        approval_record_id: Option<ApprovalRecordId>,
82        namespace: impl Into<String>,
83        target_key: impl Into<String>,
84        method_digest: ContentDigest,
85        effect_digest: ContentDigest,
86        expires_at: Option<DateTime<Utc>>,
87        nonce: impl Into<String>,
88    ) -> Self {
89        Self {
90            execution_permit_id,
91            decision_id,
92            approval_record_id,
93            scope: ToolExecutionPermitScope {
94                namespace: namespace.into(),
95                target_key: target_key.into(),
96            },
97            expires_at,
98            nonce: nonce.into(),
99            consumed: AtomicBool::new(false),
100            method_digest,
101            effect_digest,
102        }
103    }
104
105    /// Returns the execution permit identifier.
106    pub fn execution_permit_id(&self) -> &ExecutionPermitId {
107        &self.execution_permit_id
108    }
109
110    /// Returns the policy decision lineage bound to this permit.
111    pub fn decision_id(&self) -> &PolicyDecisionId {
112        &self.decision_id
113    }
114
115    /// Returns the optional approval-record lineage bound to this permit.
116    pub fn approval_record_id(&self) -> Option<&ApprovalRecordId> {
117        self.approval_record_id.as_ref()
118    }
119
120    /// Returns the namespace/target scope enforced by this permit.
121    pub fn scope(&self) -> &ToolExecutionPermitScope {
122        &self.scope
123    }
124
125    /// Returns the expiry instant, when the issuing policy imposed one.
126    pub fn expires_at(&self) -> Option<&DateTime<Utc>> {
127        self.expires_at.as_ref()
128    }
129
130    /// Returns the issuer-provided replay nonce.
131    pub fn nonce(&self) -> &str {
132        &self.nonce
133    }
134
135    /// Returns the tool method digest bound to this permit.
136    pub fn method_digest(&self) -> &ContentDigest {
137        &self.method_digest
138    }
139
140    /// Returns the typed effect digest bound to this permit.
141    pub fn effect_digest(&self) -> &ContentDigest {
142        &self.effect_digest
143    }
144
145    /// Validates expiry and exact method/effect bindings without consuming the permit.
146    pub fn validate_binding(
147        &self,
148        method_digest: &ContentDigest,
149        effect_digest: &ContentDigest,
150        now: DateTime<Utc>,
151    ) -> Result<(), ToolError> {
152        if self
153            .expires_at
154            .as_ref()
155            .is_some_and(|expiry| expiry <= &now)
156        {
157            return Err(ToolError::new(
158                ToolErrorClass::Denied,
159                "execution permit expired",
160            ));
161        }
162        if &self.method_digest != method_digest || &self.effect_digest != effect_digest {
163            return Err(ToolError::new(
164                ToolErrorClass::Denied,
165                "execution permit method/effect binding mismatch",
166            ));
167        }
168        Ok(())
169    }
170
171    /// Atomically consumes this one-shot permit.
172    pub fn consume(&self) -> Result<(), ToolError> {
173        if self
174            .expires_at
175            .as_ref()
176            .is_some_and(|expiry| expiry <= &Utc::now())
177        {
178            return Err(ToolError::new(
179                ToolErrorClass::Denied,
180                "execution permit expired",
181            ));
182        }
183        if self
184            .consumed
185            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
186            .is_err()
187        {
188            return Err(ToolError::new(
189                ToolErrorClass::Denied,
190                "execution permit already consumed",
191            ));
192        }
193        Ok(())
194    }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum ToolBackendKind {
200    LocalFunction,
201    OpenAiFunction,
202    OpenAiBuiltIn,
203    OllamaFunction,
204    RemoteMcp,
205}
206
207impl ToolBackendKind {
208    /// Returns the stable snake_case label used in serialized receipts.
209    pub fn as_str(&self) -> &'static str {
210        match self {
211            Self::LocalFunction => "local_function",
212            Self::OpenAiFunction => "open_ai_function",
213            Self::OpenAiBuiltIn => "open_ai_built_in",
214            Self::OllamaFunction => "ollama_function",
215            Self::RemoteMcp => "remote_mcp",
216        }
217    }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ToolOutputMode {
223    StructuredJson,
224    Text,
225    ArtifactRefs,
226    JobHandle,
227}
228
229#[derive(
230    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
231)]
232#[serde(rename_all = "snake_case")]
233pub enum ToolSideEffectClass {
234    ReadOnly,
235    Analysis,
236    PreviewWrite,
237    Write,
238    Admin,
239}
240
241impl fmt::Display for ToolSideEffectClass {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        f.write_str(match self {
244            Self::ReadOnly => "read-only",
245            Self::Analysis => "analysis",
246            Self::PreviewWrite => "preview-write",
247            Self::Write => "write",
248            Self::Admin => "admin",
249        })
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum ToolIdempotencyClass {
256    Idempotent,
257    BestEffort,
258    NonIdempotent,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum ToolApprovalKind {
264    None,
265    UserRequired,
266    PolicyRequired,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "snake_case")]
271pub enum ToolExposureMode {
272    Auto,
273    OptIn,
274    Hidden,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum McpSurfaceKind {
280    None,
281    Tool,
282    Resource,
283    Prompt,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
287#[serde(rename_all = "snake_case")]
288pub enum ToolReceiptPersistence {
289    #[default]
290    Durable,
291    Ephemeral,
292    /// Legacy name for durable Forge raw-receipt persistence.
293    ForgeRaw,
294}
295
296impl ToolReceiptPersistence {
297    /// Returns whether this mode requires durable preflight and outcome receipts.
298    pub fn is_durable(&self) -> bool {
299        matches!(self, Self::Durable | Self::ForgeRaw)
300    }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum ToolOriginKind {
306    OpenAiResponses,
307    OpenAiChat,
308    OllamaChat,
309    Local,
310    Mcp,
311    Test,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "snake_case")]
316pub enum ToolPlannerStage {
317    InitialRouting,
318    Retrieval,
319    Analysis,
320    Execution,
321    Audit,
322}
323
324impl ToolPlannerStage {
325    /// Returns the stable snake_case label used in serialized planning metadata.
326    pub fn as_str(&self) -> &'static str {
327        match self {
328            Self::InitialRouting => "initial_routing",
329            Self::Retrieval => "retrieval",
330            Self::Analysis => "analysis",
331            Self::Execution => "execution",
332            Self::Audit => "audit",
333        }
334    }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "snake_case")]
339pub enum ToolApprovalState {
340    NotRequired,
341    Approved,
342    Denied,
343}
344
345impl ToolApprovalState {
346    /// Returns the stable snake_case label used in serialized approval metadata.
347    pub fn as_str(&self) -> &'static str {
348        match self {
349            Self::NotRequired => "not_required",
350            Self::Approved => "approved",
351            Self::Denied => "denied",
352        }
353    }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358pub enum ToolRetryOwner {
359    LlmPipeline,
360    AgentGraph,
361    JobQueue,
362    AiBatchQueue,
363    ForgeOrchestration,
364    External,
365}
366
367impl ToolRetryOwner {
368    /// Returns the stable snake_case label used in serialized retry-owner metadata.
369    pub fn as_str(&self) -> &'static str {
370        match self {
371            Self::LlmPipeline => "llm_pipeline",
372            Self::AgentGraph => "agent_graph",
373            Self::JobQueue => "job_queue",
374            Self::AiBatchQueue => "ai_batch_queue",
375            Self::ForgeOrchestration => "forge_orchestration",
376            Self::External => "external",
377        }
378    }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum ApprovalGrantEffectClass {
384    Analysis,
385    PreviewWrite,
386    Write,
387    Admin,
388}
389
390impl ApprovalGrantEffectClass {
391    /// Maps a tool-side effect class into the approval-grant vocabulary.
392    pub fn for_tool_side_effect_class(side_effect_class: &ToolSideEffectClass) -> Option<Self> {
393        match side_effect_class {
394            ToolSideEffectClass::ReadOnly => None,
395            ToolSideEffectClass::Analysis => Some(Self::Analysis),
396            ToolSideEffectClass::PreviewWrite => Some(Self::PreviewWrite),
397            ToolSideEffectClass::Write => Some(Self::Write),
398            ToolSideEffectClass::Admin => Some(Self::Admin),
399        }
400    }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct ApprovalGrantCitation {
405    pub applicability_context_id: ApplicabilityContextId,
406    pub profile_set_id: ProfileSetId,
407    pub composition_receipt_id: CompositionReceiptId,
408    pub effective_constitution_id: EffectiveConstitutionId,
409    pub compiled_obligation_set_id: CompiledObligationSetId,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct ApprovalGrantScope {
414    pub namespace: String,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub target_key: Option<String>,
417    pub tool_name: String,
418    pub effect_class: ApprovalGrantEffectClass,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub planner_stage: Option<ToolPlannerStage>,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct ApprovalGrant {
425    pub grant_id: ApprovalGrantId,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub approval_record_id: Option<ApprovalRecordId>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub decision_id: Option<PolicyDecisionId>,
430    pub policy_version: String,
431    pub scope: ApprovalGrantScope,
432    #[serde(default)]
433    pub approver_lineage: Vec<String>,
434    pub approved_at: String,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub expires_at: Option<String>,
437    pub citation: ApprovalGrantCitation,
438}
439
440impl ApprovalGrant {
441    /// Builds a typed approval grant for one tool surface.
442    pub fn new(
443        policy_version: impl Into<String>,
444        scope: ApprovalGrantScope,
445        approver_lineage: Vec<String>,
446        approved_at: impl Into<String>,
447        expires_at: Option<String>,
448        citation: ApprovalGrantCitation,
449    ) -> Self {
450        Self {
451            grant_id: ApprovalGrantId::generate(),
452            approval_record_id: None,
453            decision_id: None,
454            policy_version: policy_version.into(),
455            scope,
456            approver_lineage,
457            approved_at: approved_at.into(),
458            expires_at,
459            citation,
460        }
461    }
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum ToolErrorClass {
467    InvalidArguments,
468    UnknownTool,
469    ApprovalRequired,
470    Denied,
471    Timeout,
472    Cancelled,
473    OutputTooLarge,
474    Execution,
475    ReceiptPersistence,
476    ProviderContract,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct ToolExposurePolicy {
481    pub exposure_mode: ToolExposureMode,
482    #[serde(default)]
483    pub allow_parallel_calls: bool,
484    #[serde(default)]
485    pub max_calls_per_turn: u32,
486    #[serde(default, skip_serializing_if = "Vec::is_empty")]
487    pub allowed_planner_stages: Vec<ToolPlannerStage>,
488}
489
490impl Default for ToolExposurePolicy {
491    fn default() -> Self {
492        Self {
493            exposure_mode: ToolExposureMode::Auto,
494            allow_parallel_calls: false,
495            max_calls_per_turn: 1,
496            allowed_planner_stages: Vec::new(),
497        }
498    }
499}
500
501/// Typed declaration of how a tool maps arguments to one or more effect targets.
502#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
503pub struct EffectTargetSpec {
504    /// Equivalent argument names for a single logical target.
505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
506    pub aliases: Vec<String>,
507    /// Argument names that jointly form a compound effect scope.
508    #[serde(default, skip_serializing_if = "Vec::is_empty")]
509    pub compound: Vec<String>,
510}
511
512/// Concrete targets covered by an effect intent.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct EffectScope {
515    pub targets: Vec<String>,
516}
517
518/// Canonical, typed description of the effect a tool call intends to perform.
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct EffectIntent {
521    pub target_key: String,
522    pub effect_class: ToolSideEffectClass,
523    pub scope: EffectScope,
524    pub canonical_args_digest: ContentDigest,
525}
526
527impl EffectIntent {
528    /// Digests the complete typed effect rather than any provider-specific JSON spelling.
529    pub fn digest(&self) -> ContentDigest {
530        digest_serializable(self)
531    }
532}
533
534#[derive(Clone, Serialize, Deserialize)]
535pub struct ToolDescriptor {
536    pub name: String,
537    pub version: String,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub description: Option<String>,
540    pub backend_kind: ToolBackendKind,
541    pub input_schema: Value,
542    pub output_mode: ToolOutputMode,
543    pub read_only: bool,
544    pub side_effect_class: ToolSideEffectClass,
545    pub idempotency_class: ToolIdempotencyClass,
546    pub approval_kind: ToolApprovalKind,
547    pub timeout_ms: u64,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub concurrency_key: Option<String>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub cache_ttl_ms: Option<u64>,
552    pub exposure_mode: ToolExposureMode,
553    pub mcp_surface_kind: McpSurfaceKind,
554    #[serde(default)]
555    pub exposure_policy: ToolExposurePolicy,
556    #[serde(default)]
557    pub receipt_persistence: ToolReceiptPersistence,
558    #[serde(default)]
559    pub effect_target: EffectTargetSpec,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub output_size_limit_bytes: Option<usize>,
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub provider_payload: Option<Value>,
564    #[serde(skip)]
565    pub rollback_contract: Option<Arc<dyn CompensatingAction + Send + Sync>>,
566}
567
568impl std::fmt::Debug for ToolDescriptor {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        f.debug_struct("ToolDescriptor")
571            .field("name", &self.name)
572            .field("version", &self.version)
573            .field("backend_kind", &self.backend_kind)
574            .field("read_only", &self.read_only)
575            .field("side_effect_class", &self.side_effect_class)
576            .field("has_rollback", &self.rollback_contract.is_some())
577            .finish()
578    }
579}
580
581impl ToolDescriptor {
582    /// Returns true when this descriptor can be dispatched through a local function bridge.
583    pub fn supports_local_dispatch(&self) -> bool {
584        matches!(
585            self.backend_kind,
586            ToolBackendKind::LocalFunction
587                | ToolBackendKind::OpenAiFunction
588                | ToolBackendKind::OllamaFunction
589        )
590    }
591
592    /// Returns the stable digest identifying this tool method and version.
593    pub fn method_digest(&self) -> ContentDigest {
594        digest_serializable(&serde_json::json!({
595            "name": self.name,
596            "version": self.version,
597        }))
598    }
599
600    /// Converts provider arguments into a typed, canonical effect intent.
601    pub fn describe_effect(&self, args: &Value) -> Result<EffectIntent, ToolError> {
602        let mut canonical_args = args.clone();
603        let targets = if !self.effect_target.compound.is_empty() {
604            self.effect_target
605                .compound
606                .iter()
607                .map(|name| required_target(args, name))
608                .collect::<Result<Vec<_>, _>>()?
609        } else if !self.effect_target.aliases.is_empty() {
610            let target = self
611                .effect_target
612                .aliases
613                .iter()
614                .find_map(|name| args.get(name).and_then(Value::as_str))
615                .ok_or_else(|| {
616                    ToolError::new(
617                        ToolErrorClass::InvalidArguments,
618                        "effect target is missing or is not a string",
619                    )
620                })?
621                .to_owned();
622            if let Value::Object(object) = &mut canonical_args {
623                for alias in &self.effect_target.aliases {
624                    object.remove(alias);
625                }
626                object.insert("$effect_target".into(), Value::String(target.clone()));
627            }
628            vec![target]
629        } else {
630            Vec::new()
631        };
632
633        let target_key = match targets.as_slice() {
634            [] => self.name.clone(),
635            [target] => target.clone(),
636            _ => serde_json::to_string(&targets).unwrap_or_else(|_| "[]".into()),
637        };
638        Ok(EffectIntent {
639            target_key,
640            effect_class: self.side_effect_class.clone(),
641            scope: EffectScope { targets },
642            canonical_args_digest: digest_serializable(&canonical_args),
643        })
644    }
645}
646
647fn required_target(args: &Value, name: &str) -> Result<String, ToolError> {
648    args.get(name)
649        .and_then(Value::as_str)
650        .map(str::to_owned)
651        .ok_or_else(|| {
652            ToolError::new(
653                ToolErrorClass::InvalidArguments,
654                format!("compound effect target {name} is missing or is not a string"),
655            )
656        })
657}
658
659fn digest_serializable(value: &impl Serialize) -> ContentDigest {
660    let mut builder = DigestBuilder::new();
661    if let Ok(value) = serde_json::to_value(value) {
662        let _ = builder.update_json(&value);
663    }
664    builder.finalize()
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668pub struct ToolBudgetContext {
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub budget_kind: Option<String>,
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub max_steps: Option<u32>,
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub time_budget_ms: Option<u64>,
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub cost_budget_units: Option<u64>,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct ToolCtx {
681    pub trace_ctx: TraceCtx,
682    pub attempt_id: AttemptId,
683    pub trial_id: TrialId,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub deadline: Option<String>,
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub workload_class: Option<String>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub budget_context: Option<ToolBudgetContext>,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub scope: Option<ScopeKey>,
692    #[serde(default)]
693    pub dry_run: bool,
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub approval_grant: Option<ApprovalGrant>,
696    #[serde(default, skip_serializing, skip_deserializing)]
697    pub execution_permit: Option<Arc<ToolExecutionPermit>>,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub idempotency_key: Option<String>,
700    pub caller: String,
701    pub planner_stage: ToolPlannerStage,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub parent_receipt_id: Option<String>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub family_receipt_id: Option<String>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub replay_parent_receipt_id: Option<String>,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub remote_oracle_lease_id: Option<RemoteOracleLeaseId>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub remote_slice_result_id: Option<RemoteSliceResultId>,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub attestation_envelope_id: Option<AttestationEnvelopeId>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub cross_runtime_replay_ticket_id: Option<CrossRuntimeReplayTicketId>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub retry_owner: Option<ToolRetryOwner>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct ToolCall {
722    pub descriptor_name: String,
723    pub descriptor_version: String,
724    pub arguments: Value,
725    pub origin_kind: ToolOriginKind,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub provider_call_id: Option<String>,
728    pub tool_run_id: String,
729}
730
731impl ToolCall {
732    /// Creates a tool call payload with a fresh tool-run identifier.
733    pub fn new(
734        descriptor_name: impl Into<String>,
735        descriptor_version: impl Into<String>,
736        arguments: Value,
737        origin_kind: ToolOriginKind,
738    ) -> Self {
739        Self {
740            descriptor_name: descriptor_name.into(),
741            descriptor_version: descriptor_version.into(),
742            arguments,
743            origin_kind,
744            provider_call_id: None,
745            tool_run_id: uuid::Uuid::new_v4().to_string(),
746        }
747    }
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct ToolArtifactRef {
752    pub artifact_id: ArtifactId,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub label: Option<String>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub mime_type: Option<String>,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct ToolJobHandle {
761    pub job_id: String,
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub status: Option<String>,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct ToolResult {
768    pub mode: ToolOutputMode,
769    pub payload: Value,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub display_text: Option<String>,
772}
773
774impl ToolResult {
775    /// Builds a structured-JSON tool result.
776    pub fn json(payload: Value) -> Self {
777        Self {
778            mode: ToolOutputMode::StructuredJson,
779            payload,
780            display_text: None,
781        }
782    }
783
784    /// Builds a text-mode tool result.
785    pub fn text(text: impl Into<String>) -> Self {
786        let text = text.into();
787        Self {
788            mode: ToolOutputMode::Text,
789            payload: Value::String(text.clone()),
790            display_text: Some(text),
791        }
792    }
793
794    /// Builds an artifact-reference tool result.
795    pub fn artifact_refs(refs: Vec<ToolArtifactRef>) -> Self {
796        Self {
797            mode: ToolOutputMode::ArtifactRefs,
798            payload: serde_json::to_value(refs).unwrap_or(Value::Array(Vec::new())),
799            display_text: None,
800        }
801    }
802
803    /// Builds a job-handle tool result.
804    pub fn job_handle(handle: ToolJobHandle) -> Self {
805        Self {
806            mode: ToolOutputMode::JobHandle,
807            payload: serde_json::to_value(handle).unwrap_or(Value::Null),
808            display_text: None,
809        }
810    }
811
812    /// Renders the result into the string form exposed back to the model.
813    pub fn to_model_output(&self) -> String {
814        match self.mode {
815            ToolOutputMode::Text => self
816                .display_text
817                .clone()
818                .unwrap_or_else(|| self.payload.as_str().unwrap_or_default().to_string()),
819            ToolOutputMode::StructuredJson
820            | ToolOutputMode::ArtifactRefs
821            | ToolOutputMode::JobHandle => serde_json::to_string(&self.payload)
822                .unwrap_or_else(|_| "\"<tool_result_unserializable>\"".to_string()),
823        }
824    }
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, Error)]
828#[error("{class:?}: {message}")]
829pub struct ToolError {
830    pub class: ToolErrorClass,
831    pub message: String,
832    #[serde(default)]
833    pub retryable: bool,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub details: Option<Value>,
836}
837
838impl ToolError {
839    /// Creates a non-retryable tool error with the supplied class and message.
840    pub fn new(class: ToolErrorClass, message: impl Into<String>) -> Self {
841        Self {
842            class,
843            message: message.into(),
844            retryable: false,
845            details: None,
846        }
847    }
848}
849
850/// One offline-verifiable hop in the authority chain for an execution receipt.
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852pub struct AuthorityLineageEntry {
853    pub origin_class: String,
854    pub principal: String,
855    pub permit_id: String,
856    pub policy_version: String,
857}
858
859/// Verifies that authority lineage is populated and covers the complete execution chain.
860pub fn verify_authority_lineage(lineage: &[AuthorityLineageEntry]) -> Result<(), ToolError> {
861    const REQUIRED: [&str; 5] = ["request", "policy", "approval", "permit", "effect"];
862    if lineage.iter().any(|entry| {
863        entry.origin_class.is_empty()
864            || entry.principal.is_empty()
865            || entry.permit_id.is_empty()
866            || entry.policy_version.is_empty()
867    }) {
868        return Err(ToolError::new(
869            ToolErrorClass::ProviderContract,
870            "authority lineage contains an incomplete entry",
871        ));
872    }
873    if REQUIRED
874        .iter()
875        .any(|required| !lineage.iter().any(|entry| entry.origin_class == *required))
876    {
877        return Err(ToolError::new(
878            ToolErrorClass::ProviderContract,
879            "authority lineage does not cover request, policy, approval, permit, and effect",
880        ));
881    }
882    Ok(())
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
886#[serde(rename_all = "snake_case")]
887pub enum ToolReceiptPhase {
888    Preflight,
889    #[default]
890    Outcome,
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
894#[serde(rename_all = "snake_case")]
895pub enum ToolReceiptResolution {
896    Pending,
897    #[default]
898    Resolved,
899    Unresolved,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
903pub struct ToolReceipt {
904    pub receipt_id: String,
905    pub tool_name: String,
906    pub tool_version: String,
907    pub backend_kind: ToolBackendKind,
908    pub input_digest: ContentDigest,
909    pub output_digest_or_refs: Value,
910    pub policy_hash: ContentDigest,
911    pub approval_state: ToolApprovalState,
912    #[serde(default)]
913    pub phase: ToolReceiptPhase,
914    #[serde(default)]
915    pub resolution: ToolReceiptResolution,
916    #[serde(default)]
917    pub authority_lineage: Vec<AuthorityLineageEntry>,
918    pub host_identity: String,
919    pub started_at: String,
920    pub finished_at: String,
921    pub trace_ctx: TraceCtx,
922    pub attempt_id: AttemptId,
923    pub trial_id: TrialId,
924    pub planner_stage: ToolPlannerStage,
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub deadline: Option<String>,
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub workload_class: Option<String>,
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    pub budget_context: Option<ToolBudgetContext>,
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub parent_receipt_id: Option<String>,
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub preflight_receipt_id: Option<String>,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub family_receipt_id: Option<String>,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub replay_parent_receipt_id: Option<String>,
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub remote_oracle_lease_id: Option<RemoteOracleLeaseId>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub remote_slice_result_id: Option<RemoteSliceResultId>,
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub attestation_envelope_id: Option<AttestationEnvelopeId>,
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub cross_runtime_replay_ticket_id: Option<CrossRuntimeReplayTicketId>,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub error_class: Option<ToolErrorClass>,
949    pub retry_owner: ToolRetryOwner,
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub replay_link: Option<String>,
952    pub tool_run_id: String,
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub provider_call_id: Option<String>,
955}
956
957impl ToolReceipt {
958    /// Verifies the embedded request-to-effect authority chain offline.
959    pub fn verify_authority_lineage(&self) -> Result<(), ToolError> {
960        verify_authority_lineage(&self.authority_lineage)
961    }
962
963    /// Normalizes a runtime-native tool receipt into the canonical Forge receipt schema.
964    pub fn to_forge_tool_receipt_v2(
965        &self,
966        raw_payload: Value,
967    ) -> semantic_memory_forge::ForgeToolReceiptV2 {
968        semantic_memory_forge::ForgeToolReceiptV2 {
969            schema_version: semantic_memory_forge::FORGE_TOOL_RECEIPT_V2_SCHEMA.into(),
970            receipt_id: self.receipt_id.clone(),
971            tool_run_id: self.tool_run_id.clone(),
972            tool_name: self.tool_name.clone(),
973            tool_version: self.tool_version.clone(),
974            backend_kind: self.backend_kind.as_str().into(),
975            input_digest: self.input_digest.clone(),
976            output_digest_or_refs: self.output_digest_or_refs.clone(),
977            policy_hash: self.policy_hash.clone(),
978            approval_state: self.approval_state.as_str().into(),
979            host_identity: self.host_identity.clone(),
980            started_at: self.started_at.clone(),
981            finished_at: self.finished_at.clone(),
982            trace_ctx: self.trace_ctx.clone(),
983            attempt_id: self.attempt_id.clone(),
984            trial_id: self.trial_id.clone(),
985            planner_stage: self.planner_stage.as_str().into(),
986            deadline: self.deadline.clone(),
987            workload_class: self.workload_class.clone(),
988            budget_context: self.budget_context.as_ref().map(|budget| {
989                semantic_memory_forge::ForgeToolBudgetContext {
990                    budget_kind: budget.budget_kind.clone(),
991                    max_steps: budget.max_steps,
992                    time_budget_ms: budget.time_budget_ms,
993                    cost_budget_units: budget.cost_budget_units,
994                }
995            }),
996            parent_receipt_id: self.parent_receipt_id.clone(),
997            family_receipt_id: self.family_receipt_id.clone(),
998            replay_parent_receipt_id: self.replay_parent_receipt_id.clone(),
999            error_class: self.error_class.as_ref().map(|class| format!("{class:?}")),
1000            retry_owner: self.retry_owner.as_str().into(),
1001            replay_link: self.replay_link.clone(),
1002            provider_call_id: self.provider_call_id.clone(),
1003            raw_payload,
1004        }
1005    }
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct ToolEffectDispatchReceiptV1 {
1010    pub schema_version: String,
1011    pub tool_effect_dispatch_receipt_id: ToolEffectDispatchReceiptId,
1012    pub effect_commit_decision_id: EffectCommitDecisionId,
1013    pub tool_receipt_id: String,
1014    pub provider_route: Vec<String>,
1015    pub dispatch_state: String,
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub effect_execution_receipt_id: Option<EffectExecutionReceiptId>,
1018    pub deadline_at_dispatch: String,
1019    pub cancellation_reason: String,
1020}