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
30pub 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 pub fn namespace(&self) -> &str {
54 &self.namespace
55 }
56
57 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 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 pub fn execution_permit_id(&self) -> &ExecutionPermitId {
107 &self.execution_permit_id
108 }
109
110 pub fn decision_id(&self) -> &PolicyDecisionId {
112 &self.decision_id
113 }
114
115 pub fn approval_record_id(&self) -> Option<&ApprovalRecordId> {
117 self.approval_record_id.as_ref()
118 }
119
120 pub fn scope(&self) -> &ToolExecutionPermitScope {
122 &self.scope
123 }
124
125 pub fn expires_at(&self) -> Option<&DateTime<Utc>> {
127 self.expires_at.as_ref()
128 }
129
130 pub fn nonce(&self) -> &str {
132 &self.nonce
133 }
134
135 pub fn method_digest(&self) -> &ContentDigest {
137 &self.method_digest
138 }
139
140 pub fn effect_digest(&self) -> &ContentDigest {
142 &self.effect_digest
143 }
144
145 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 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 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 ForgeRaw,
294}
295
296impl ToolReceiptPersistence {
297 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 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 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 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
503pub struct EffectTargetSpec {
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
506 pub aliases: Vec<String>,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
509 pub compound: Vec<String>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct EffectScope {
515 pub targets: Vec<String>,
516}
517
518#[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 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 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 pub fn method_digest(&self) -> ContentDigest {
594 digest_serializable(&serde_json::json!({
595 "name": self.name,
596 "version": self.version,
597 }))
598 }
599
600 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 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 pub fn json(payload: Value) -> Self {
777 Self {
778 mode: ToolOutputMode::StructuredJson,
779 payload,
780 display_text: None,
781 }
782 }
783
784 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 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 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 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 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#[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
859pub 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 pub fn verify_authority_lineage(&self) -> Result<(), ToolError> {
960 verify_authority_lineage(&self.authority_lineage)
961 }
962
963 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}