1use crate::reversibility::{Compensation, Reversibility};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use uuid::Uuid;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ActionType {
14 ToolCall,
15 StateWrite,
16 StateRead,
17 Assertion,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum FailureBehavior {
24 #[default]
25 Abort,
26 Retry,
27 Skip,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
37#[serde(rename_all = "snake_case")]
38pub enum ToolFailureClassification {
39 #[default]
41 Ordinary,
42 Terminal,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ToolFailure {
52 pub message: String,
53 #[serde(default)]
54 pub classification: ToolFailureClassification,
55}
56
57impl ToolFailure {
58 pub fn ordinary(message: impl Into<String>) -> Self {
59 Self {
60 message: message.into(),
61 classification: ToolFailureClassification::Ordinary,
62 }
63 }
64
65 pub fn terminal(message: impl Into<String>) -> Self {
66 Self {
67 message: message.into(),
68 classification: ToolFailureClassification::Terminal,
69 }
70 }
71
72 pub const fn is_terminal(&self) -> bool {
77 match self.classification {
78 ToolFailureClassification::Ordinary => false,
79 ToolFailureClassification::Terminal => true,
80 }
81 }
82}
83
84impl From<String> for ToolFailure {
85 fn from(message: String) -> Self {
86 Self::ordinary(message)
87 }
88}
89
90impl From<&str> for ToolFailure {
91 fn from(message: &str) -> Self {
92 Self::ordinary(message)
93 }
94}
95
96impl std::fmt::Display for ToolFailure {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 formatter.write_str(&self.message)
99 }
100}
101
102impl std::error::Error for ToolFailure {}
103
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum ActionStatus {
108 Proposed,
109 Validated,
110 Rejected,
111 Executing,
112 Succeeded,
113 Failed,
114 Skipped,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Precondition {
122 pub key: String,
123 #[serde(default = "default_operator")]
125 pub operator: String,
126 #[serde(default)]
127 pub value: Value,
128 #[serde(default)]
129 pub description: String,
130}
131
132fn default_operator() -> String {
133 "eq".to_string()
134}
135
136fn short_id() -> String {
138 Uuid::new_v4().simple().to_string()[..12].to_string()
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[non_exhaustive]
163pub struct Action {
164 #[serde(default = "short_id")]
165 pub id: String,
166
167 #[serde(rename = "type")]
168 pub action_type: ActionType,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub tool: Option<String>,
172
173 #[serde(default)]
174 pub parameters: HashMap<String, Value>,
175
176 #[serde(default)]
177 pub preconditions: Vec<Precondition>,
178
179 #[serde(default)]
180 pub expected_effects: HashMap<String, Value>,
181
182 #[serde(default)]
183 pub state_dependencies: Vec<String>,
184
185 #[serde(default)]
193 pub read_set: Vec<String>,
194
195 #[serde(default)]
199 pub write_set: Vec<String>,
200
201 #[serde(default)]
206 pub assumptions: Vec<StateAssumption>,
207
208 #[serde(default)]
215 pub invocation_mode: crate::tool_stream::ToolInvocationMode,
216
217 #[serde(default)]
228 pub reversibility: Reversibility,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub compensation: Option<Compensation>,
244
245 #[serde(default)]
246 pub idempotent: bool,
247
248 #[serde(default = "default_max_retries")]
249 pub max_retries: u32,
250
251 #[serde(default)]
252 pub failure_behavior: FailureBehavior,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub timeout_ms: Option<u64>,
256
257 #[serde(default)]
258 pub metadata: HashMap<String, Value>,
259}
260
261fn default_max_retries() -> u32 {
262 3
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272pub struct StateAssumption {
273 pub key: String,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub expected_value: Option<Value>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub read_version: Option<u64>,
282}
283
284impl Action {
285 pub fn new(action_type: ActionType) -> Self {
306 Self {
307 id: short_id(),
308 action_type,
309 tool: None,
310 parameters: HashMap::new(),
311 preconditions: Vec::new(),
312 expected_effects: HashMap::new(),
313 state_dependencies: Vec::new(),
314 read_set: Vec::new(),
315 write_set: Vec::new(),
316 assumptions: Vec::new(),
317 invocation_mode: crate::tool_stream::ToolInvocationMode::default(),
318 reversibility: Reversibility::default(),
319 compensation: None,
320 idempotent: false,
321 max_retries: default_max_retries(),
322 failure_behavior: FailureBehavior::default(),
323 timeout_ms: None,
324 metadata: HashMap::new(),
325 }
326 }
327
328 pub fn tool_call(tool: impl Into<String>) -> Self {
330 let mut a = Self::new(ActionType::ToolCall);
331 a.tool = Some(tool.into());
332 a
333 }
334
335 pub fn state_write(key: impl Into<String>, value: Value) -> Self {
339 let key = key.into();
340 let mut a = Self::new(ActionType::StateWrite);
341 a.parameters
342 .insert("key".to_string(), Value::String(key.clone()));
343 a.parameters.insert("value".to_string(), value.clone());
344 a.expected_effects.insert(key, value);
345 a
346 }
347
348 pub fn state_read(key: impl Into<String>) -> Self {
350 let mut a = Self::new(ActionType::StateRead);
351 a.parameters
352 .insert("key".to_string(), Value::String(key.into()));
353 a
354 }
355
356 pub fn with_id(mut self, id: impl Into<String>) -> Self {
359 self.id = id.into();
360 self
361 }
362
363 pub fn with_param(mut self, key: impl Into<String>, value: Value) -> Self {
365 self.parameters.insert(key.into(), value);
366 self
367 }
368
369 pub fn effective_write_set(&self) -> Vec<String> {
378 let mut keys: Vec<String> = self.write_set.clone();
379 let push = |k: String, keys: &mut Vec<String>| {
380 if !keys.contains(&k) {
381 keys.push(k);
382 }
383 };
384 for k in self.expected_effects.keys() {
385 push(k.clone(), &mut keys);
386 }
387 if self.action_type == ActionType::StateWrite {
388 if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
389 push(k.to_string(), &mut keys);
390 }
391 }
392 keys
393 }
394
395 pub fn effective_read_set(&self) -> Vec<String> {
400 let mut keys: Vec<String> = self.read_set.clone();
401 let push = |k: String, keys: &mut Vec<String>| {
402 if !keys.contains(&k) {
403 keys.push(k);
404 }
405 };
406 for k in &self.state_dependencies {
407 push(k.clone(), &mut keys);
408 }
409 for a in &self.assumptions {
410 push(a.key.clone(), &mut keys);
411 }
412 if self.action_type == ActionType::StateRead {
413 if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
414 push(k.to_string(), &mut keys);
415 }
416 }
417 keys
418 }
419
420 pub fn missing_required_compensation(&self) -> bool {
431 self.reversibility.requires_compensation() && self.compensation.is_none()
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437pub struct ActionProposal {
438 #[serde(default = "short_id")]
439 pub id: String,
440
441 #[serde(default = "default_source")]
442 pub source: String,
443
444 pub actions: Vec<Action>,
445
446 #[serde(default = "Utc::now")]
447 pub timestamp: DateTime<Utc>,
448
449 #[serde(default)]
450 pub context: HashMap<String, Value>,
451}
452
453fn default_source() -> String {
454 "unknown".to_string()
455}
456
457impl ActionProposal {
458 pub fn rollback_contract(&self) -> Reversibility {
471 self.actions
472 .iter()
473 .map(|a| a.reversibility)
474 .max()
475 .unwrap_or(Reversibility::Reversible)
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
487#[serde(tag = "op", rename_all = "snake_case")]
488pub enum StateMutation {
489 Set {
490 #[serde(default)]
493 value: Value,
494 },
495 Delete,
496}
497
498impl StateMutation {
499 pub fn from_new_value(new_value: Option<Value>) -> Self {
501 match new_value {
502 Some(value) => Self::Set { value },
503 None => Self::Delete,
504 }
505 }
506
507 pub fn encode(self) -> Value {
509 serde_json::to_value(self)
510 .expect("StateMutation contains only infallibly serializable JSON values")
511 }
512
513 pub fn decode(value: &Value) -> Result<Self, serde_json::Error> {
515 serde_json::from_value(value.clone())
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct ActionResult {
522 pub action_id: String,
523 pub status: ActionStatus,
524
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub output: Option<Value>,
527
528 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub error: Option<String>,
530
531 #[serde(default, skip_serializing_if = "is_false")]
534 pub terminal: bool,
535
536 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
545 pub rolled_back: bool,
546
547 #[serde(default)]
548 pub state_changes: HashMap<String, Value>,
549
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub duration_ms: Option<f64>,
552
553 #[serde(default = "Utc::now")]
554 pub timestamp: DateTime<Utc>,
555}
556
557fn is_false(value: &bool) -> bool {
558 !value
559}
560
561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
563pub struct ToolRateLimit {
564 pub max_calls: u32,
565 pub interval_secs: f64,
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
574#[serde(rename_all = "snake_case")]
575pub enum ToolSourceKind {
576 Builtin,
577 #[default]
578 UserDefined,
579 Subprocess,
580 Mcp,
581}
582
583impl ToolSourceKind {
584 pub const fn as_str(self) -> &'static str {
585 match self {
586 Self::Builtin => "builtin",
587 Self::UserDefined => "user_defined",
588 Self::Subprocess => "subprocess",
589 Self::Mcp => "mcp",
590 }
591 }
592}
593
594#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
599pub struct ToolSchema {
600 pub name: String,
601 #[serde(default)]
604 pub source: ToolSourceKind,
605 #[serde(default)]
606 pub description: String,
607 #[serde(default = "default_parameters_schema")]
609 pub parameters: Value,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub returns: Option<Value>,
613 #[serde(default)]
615 pub idempotent: bool,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub cache_ttl_secs: Option<u64>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub rate_limit: Option<ToolRateLimit>,
622}
623
624fn default_parameters_schema() -> Value {
625 Value::Object(Default::default())
626}
627
628#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
630pub struct CostSummary {
631 pub tool_calls: u32,
632 pub actions_executed: u32,
639 #[serde(default)]
646 pub actions_rejected: u32,
647 pub actions_skipped: u32,
648 pub total_duration_ms: f64,
649 pub retries: u32,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct CostTarget {
661 pub target_tool_calls: u32,
663 pub target_duration_ms: f64,
665 pub target_actions: u32,
667 pub cost_weight: f64,
669}
670
671impl Default for CostTarget {
672 fn default() -> Self {
673 Self {
674 target_tool_calls: 5,
675 target_duration_ms: 5000.0,
676 target_actions: 10,
677 cost_weight: 0.2,
678 }
679 }
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
684#[serde(rename_all = "snake_case")]
685pub enum ProposalLineageStatus {
686 Accepted,
687 Rejected,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
694pub struct ProposalLineageEntry {
695 pub generation: u32,
696 pub proposal_id: String,
697 pub proposal_digest: Option<String>,
698 pub status: ProposalLineageStatus,
699 pub rejection_reason: Option<String>,
700}
701
702impl ProposalLineageEntry {
703 fn validate(&self) -> Result<(), String> {
704 let digest_is_lowercase_sha256 = |digest: &str| {
705 digest.len() == 64
706 && digest
707 .bytes()
708 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
709 };
710
711 match self.status {
712 ProposalLineageStatus::Accepted => {
713 let digest = self.proposal_digest.as_deref().ok_or_else(|| {
714 "accepted proposal lineage requires a lowercase JCS SHA-256 digest".to_string()
715 })?;
716 if !digest_is_lowercase_sha256(digest) {
717 return Err(
718 "accepted proposal lineage requires a lowercase JCS SHA-256 digest"
719 .to_string(),
720 );
721 }
722 if self.rejection_reason.is_some() {
723 return Err(
724 "accepted proposal lineage cannot include a rejection reason".to_string(),
725 );
726 }
727 }
728 ProposalLineageStatus::Rejected => {
729 if let Some(digest) = self.proposal_digest.as_deref() {
730 if !digest_is_lowercase_sha256(digest) {
731 return Err(
732 "rejected proposal lineage digest must be lowercase JCS SHA-256"
733 .to_string(),
734 );
735 }
736 }
737 let reason = self
738 .rejection_reason
739 .as_deref()
740 .filter(|reason| !reason.trim().is_empty())
741 .ok_or_else(|| {
742 "rejected proposal lineage requires an exact rejection reason".to_string()
743 })?;
744 if self.proposal_digest.is_none()
745 && !reason.starts_with("RFC 8785 canonicalization failed")
746 && !reason.starts_with("proposal serialization failed")
747 {
748 return Err(
749 "undigested rejected proposal lineage must identify a JCS/I-JSON failure"
750 .to_string(),
751 );
752 }
753 }
754 }
755 Ok(())
756 }
757}
758
759impl Serialize for ProposalLineageEntry {
760 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
761 where
762 S: serde::Serializer,
763 {
764 use serde::ser::{Error as _, SerializeStruct};
765 self.validate().map_err(S::Error::custom)?;
766 let mut state = serializer.serialize_struct("ProposalLineageEntry", 5)?;
767 state.serialize_field("generation", &self.generation)?;
768 state.serialize_field("proposal_id", &self.proposal_id)?;
769 if let Some(digest) = &self.proposal_digest {
770 state.serialize_field("proposal_digest", digest)?;
771 }
772 state.serialize_field("status", &self.status)?;
773 if let Some(reason) = &self.rejection_reason {
774 state.serialize_field("rejection_reason", reason)?;
775 }
776 state.end()
777 }
778}
779
780impl<'de> Deserialize<'de> for ProposalLineageEntry {
781 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
782 where
783 D: serde::Deserializer<'de>,
784 {
785 #[derive(Deserialize)]
786 struct Wire {
787 generation: u32,
788 proposal_id: String,
789 #[serde(default)]
790 proposal_digest: Option<String>,
791 status: ProposalLineageStatus,
792 #[serde(default)]
793 rejection_reason: Option<String>,
794 }
795
796 let wire = Wire::deserialize(deserializer)?;
797 let entry = Self {
798 generation: wire.generation,
799 proposal_id: wire.proposal_id,
800 proposal_digest: wire.proposal_digest,
801 status: wire.status,
802 rejection_reason: wire.rejection_reason,
803 };
804 entry.validate().map_err(serde::de::Error::custom)?;
805 Ok(entry)
806 }
807}
808
809#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct AcceptedProposalPreimage {
815 pub generation: u32,
816 pub proposal_digest: String,
817 pub proposal: ActionProposal,
818}
819
820#[derive(Debug, Clone, PartialEq, Serialize)]
822pub struct ProposalResult {
823 pub proposal_id: String,
826
827 pub original_proposal_id: String,
829
830 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub final_proposal: Option<ActionProposal>,
837
838 #[serde(default, skip_serializing_if = "Vec::is_empty")]
840 pub replan_lineage: Vec<ProposalLineageEntry>,
841
842 #[serde(default, skip_serializing_if = "Vec::is_empty")]
846 pub accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
847
848 #[serde(default)]
849 pub results: Vec<ActionResult>,
850
851 #[serde(default)]
852 pub cost: CostSummary,
853}
854
855impl<'de> Deserialize<'de> for ProposalResult {
856 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
857 where
858 D: serde::Deserializer<'de>,
859 {
860 #[derive(Deserialize)]
861 struct Wire {
862 proposal_id: String,
863 #[serde(default)]
864 original_proposal_id: Option<String>,
865 #[serde(default)]
866 final_proposal: Option<ActionProposal>,
867 #[serde(default)]
868 replan_lineage: Vec<ProposalLineageEntry>,
869 #[serde(default)]
870 accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
871 #[serde(default)]
872 results: Vec<ActionResult>,
873 #[serde(default)]
874 cost: CostSummary,
875 }
876
877 let wire = Wire::deserialize(deserializer)?;
878 let original_proposal_id = wire
879 .original_proposal_id
880 .unwrap_or_else(|| wire.proposal_id.clone());
881 Ok(Self {
882 proposal_id: wire.proposal_id,
883 original_proposal_id,
884 final_proposal: wire.final_proposal,
885 replan_lineage: wire.replan_lineage,
886 accepted_proposal_preimages: wire.accepted_proposal_preimages,
887 results: wire.results,
888 cost: wire.cost,
889 })
890 }
891}
892
893impl ProposalResult {
894 pub fn new(
895 proposal_id: impl Into<String>,
896 results: Vec<ActionResult>,
897 cost: CostSummary,
898 ) -> Self {
899 let proposal_id = proposal_id.into();
900 Self {
901 original_proposal_id: proposal_id.clone(),
902 proposal_id,
903 final_proposal: None,
904 replan_lineage: Vec::new(),
905 accepted_proposal_preimages: Vec::new(),
906 results,
907 cost,
908 }
909 }
910
911 pub fn for_proposal(
915 proposal: &ActionProposal,
916 results: Vec<ActionResult>,
917 cost: CostSummary,
918 ) -> Self {
919 Self {
920 proposal_id: proposal.id.clone(),
921 original_proposal_id: proposal.id.clone(),
922 final_proposal: Some(proposal.clone()),
923 replan_lineage: Vec::new(),
924 accepted_proposal_preimages: Vec::new(),
925 results,
926 cost,
927 }
928 }
929
930 pub fn all_succeeded(&self) -> bool {
931 self.results
932 .iter()
933 .all(|r| r.status == ActionStatus::Succeeded)
934 }
935
936 pub fn summary(&self) -> HashMap<ActionStatus, usize> {
937 let mut counts = HashMap::new();
938 for r in &self.results {
939 *counts.entry(r.status.clone()).or_insert(0) += 1;
940 }
941 counts
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use pretty_assertions::assert_eq;
949
950 #[test]
951 fn constructors_fill_defaults_and_leave_the_wire_unchanged() {
952 let a = Action::new(ActionType::ToolCall);
958 assert_eq!(a.action_type, ActionType::ToolCall);
959 assert!(a.tool.is_none());
960 assert!(a.parameters.is_empty());
961 assert!(a.preconditions.is_empty());
962 assert!(a.expected_effects.is_empty());
963 assert!(a.state_dependencies.is_empty());
964 assert!(a.read_set.is_empty());
965 assert!(a.write_set.is_empty());
966 assert!(a.assumptions.is_empty());
967 assert_eq!(a.reversibility, Reversibility::default());
968 assert!(a.compensation.is_none());
969 assert!(!a.idempotent);
970 assert_eq!(a.max_retries, default_max_retries());
971 assert_eq!(a.failure_behavior, FailureBehavior::Abort);
972 assert!(a.timeout_ms.is_none());
973 assert!(a.metadata.is_empty());
974 assert_eq!(a.id.len(), 12, "id is a generated short id");
975
976 assert_eq!(Action::tool_call("deploy").tool.as_deref(), Some("deploy"));
978
979 let sw = Action::state_write("k", Value::from(1));
980 assert_eq!(sw.action_type, ActionType::StateWrite);
981 assert_eq!(sw.parameters["key"], Value::from("k"));
982 assert_eq!(sw.parameters["value"], Value::from(1));
983 assert_eq!(sw.expected_effects["k"], Value::from(1));
987 assert_eq!(sw.effective_write_set(), vec!["k".to_string()]);
988
989 let sr = Action::state_read("k");
990 assert_eq!(sr.action_type, ActionType::StateRead);
991 assert_eq!(sr.parameters["key"], Value::from("k"));
992
993 let c = Action::tool_call("t")
995 .with_id("fixed")
996 .with_param("p", Value::from(2));
997 assert_eq!(c.id, "fixed");
998 assert_eq!(c.parameters["p"], Value::from(2));
999 }
1000
1001 #[test]
1002 fn action_type_serializes_snake_case() {
1003 assert_eq!(
1004 serde_json::to_string(&ActionType::ToolCall).unwrap(),
1005 "\"tool_call\""
1006 );
1007 assert_eq!(
1008 serde_json::to_string(&ActionType::StateWrite).unwrap(),
1009 "\"state_write\""
1010 );
1011 }
1012
1013 #[test]
1014 fn failure_behavior_serializes_snake_case() {
1015 assert_eq!(
1016 serde_json::to_string(&FailureBehavior::Abort).unwrap(),
1017 "\"abort\""
1018 );
1019 assert_eq!(
1020 serde_json::to_string(&FailureBehavior::Retry).unwrap(),
1021 "\"retry\""
1022 );
1023 }
1024
1025 #[test]
1026 fn action_roundtrip_json() {
1027 let action = Action {
1028 id: "abc123".to_string(),
1029 action_type: ActionType::ToolCall,
1030 tool: Some("add".to_string()),
1031 parameters: [
1032 ("a".to_string(), Value::from(1)),
1033 ("b".to_string(), Value::from(2)),
1034 ]
1035 .into(),
1036 preconditions: vec![Precondition {
1037 key: "auth".to_string(),
1038 operator: "eq".to_string(),
1039 value: Value::Bool(true),
1040 description: String::new(),
1041 }],
1042 expected_effects: [("sum".to_string(), Value::from(3))].into(),
1043 state_dependencies: vec!["auth".to_string()],
1044 read_set: vec![],
1045 write_set: vec![],
1046 assumptions: vec![],
1047 invocation_mode: Default::default(),
1048 reversibility: Reversibility::Compensable,
1049 compensation: Some(Compensation::Tool {
1050 tool: "subtract".to_string(),
1051 parameters: [("sum".to_string(), Value::from(3))].into(),
1052 }),
1053 idempotent: true,
1054 max_retries: 3,
1055 failure_behavior: FailureBehavior::Retry,
1056 timeout_ms: Some(5000),
1057 metadata: HashMap::new(),
1058 };
1059
1060 let json = serde_json::to_string_pretty(&action).unwrap();
1061 let roundtripped: Action = serde_json::from_str(&json).unwrap();
1062
1063 assert_eq!(action.id, roundtripped.id);
1064 assert_eq!(action.action_type, roundtripped.action_type);
1065 assert_eq!(action.tool, roundtripped.tool);
1066 assert_eq!(action.idempotent, roundtripped.idempotent);
1067 assert_eq!(action.failure_behavior, roundtripped.failure_behavior);
1068 assert_eq!(action.timeout_ms, roundtripped.timeout_ms);
1069 assert_eq!(action.reversibility, roundtripped.reversibility);
1070 assert_eq!(action.compensation, roundtripped.compensation);
1071 assert_eq!(action, roundtripped);
1072 }
1073
1074 #[test]
1075 fn proposal_roundtrip_json() {
1076 let proposal = ActionProposal {
1077 id: "prop1".to_string(),
1078 source: "test".to_string(),
1079 actions: vec![Action {
1080 id: "a1".to_string(),
1081 action_type: ActionType::StateWrite,
1082 tool: None,
1083 parameters: [
1084 ("key".to_string(), Value::from("x")),
1085 ("value".to_string(), Value::from(42)),
1086 ]
1087 .into(),
1088 preconditions: vec![],
1089 expected_effects: HashMap::new(),
1090 state_dependencies: vec![],
1091 read_set: vec![],
1092 write_set: vec![],
1093 assumptions: vec![],
1094 invocation_mode: Default::default(),
1095 reversibility: Reversibility::Reversible,
1096 compensation: None,
1097 idempotent: false,
1098 max_retries: 3,
1099 failure_behavior: FailureBehavior::Abort,
1100 timeout_ms: None,
1101 metadata: HashMap::new(),
1102 }],
1103 timestamp: Utc::now(),
1104 context: HashMap::new(),
1105 };
1106
1107 let json = serde_json::to_string(&proposal).unwrap();
1108 let roundtripped: ActionProposal = serde_json::from_str(&json).unwrap();
1109
1110 assert_eq!(proposal.id, roundtripped.id);
1111 assert_eq!(proposal.source, roundtripped.source);
1112 assert_eq!(proposal.actions.len(), roundtripped.actions.len());
1113 }
1114
1115 #[test]
1116 fn state_mutation_round_trips_the_stable_wire_shape() {
1117 let set = StateMutation::Set {
1118 value: Value::from(42),
1119 };
1120 let set_wire = set.clone().encode();
1121 assert_eq!(set_wire, serde_json::json!({"op": "set", "value": 42}));
1122 assert_eq!(StateMutation::decode(&set_wire).unwrap(), set);
1123
1124 let delete = StateMutation::Delete;
1125 let delete_wire = delete.clone().encode();
1126 assert_eq!(delete_wire, serde_json::json!({"op": "delete"}));
1127 assert_eq!(StateMutation::decode(&delete_wire).unwrap(), delete);
1128
1129 assert_eq!(
1130 StateMutation::decode(&serde_json::json!({"op": "set"})).unwrap(),
1131 StateMutation::Set { value: Value::Null },
1132 "a missing set value retains the previous set-to-null interpretation"
1133 );
1134 assert!(StateMutation::decode(&serde_json::json!({"legacy": true})).is_err());
1135 }
1136
1137 #[test]
1138 fn action_result_serializes() {
1139 let result = ActionResult {
1140 action_id: "a1".to_string(),
1141 status: ActionStatus::Succeeded,
1142 output: Some(Value::from(42)),
1143 error: None,
1144 terminal: false,
1145 state_changes: HashMap::new(),
1146 rolled_back: false,
1147 duration_ms: Some(1.5),
1148 timestamp: Utc::now(),
1149 };
1150
1151 let json = serde_json::to_string(&result).unwrap();
1152 assert!(json.contains("\"succeeded\""));
1153 assert!(!json.contains("\"terminal\""));
1154 assert!(
1155 !json.contains("rolled_back"),
1156 "the additive false marker stays absent on the wire"
1157 );
1158
1159 let mut rolled_back = result;
1160 rolled_back.rolled_back = true;
1161 let value = serde_json::to_value(&rolled_back).unwrap();
1162 assert_eq!(value["rolled_back"], true);
1163
1164 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1165 "action_id": "legacy",
1166 "status": "succeeded",
1167 "state_changes": {}
1168 }))
1169 .unwrap();
1170 assert!(!legacy.rolled_back);
1171 }
1172
1173 #[test]
1174 fn terminal_tool_failure_is_explicit_and_roundtrips_on_action_results() {
1175 let ordinary = ToolFailure::from("ordinary failure");
1176 assert_eq!(ordinary.classification, ToolFailureClassification::Ordinary);
1177 assert!(!ordinary.is_terminal());
1178
1179 let failure = ToolFailure::terminal("stop now");
1180 assert_eq!(failure.classification, ToolFailureClassification::Terminal);
1181 assert!(failure.is_terminal());
1182 assert_eq!(
1183 serde_json::to_value(&failure).unwrap(),
1184 serde_json::json!({
1185 "message": "stop now",
1186 "classification": "terminal"
1187 })
1188 );
1189
1190 let result: ActionResult = serde_json::from_value(serde_json::json!({
1191 "action_id": "a1",
1192 "status": "failed",
1193 "error": "stop now",
1194 "terminal": true
1195 }))
1196 .unwrap();
1197 assert!(result.terminal);
1198 assert!(!result.rolled_back);
1199
1200 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1201 "action_id": "a2",
1202 "status": "failed",
1203 "error": "ordinary failure"
1204 }))
1205 .unwrap();
1206 assert!(!legacy.terminal);
1207 assert!(!legacy.rolled_back);
1208 }
1209
1210 #[test]
1211 fn proposal_result_all_succeeded() {
1212 let pr = ProposalResult {
1213 proposal_id: "p1".to_string(),
1214 original_proposal_id: "p1".to_string(),
1215 final_proposal: None,
1216 replan_lineage: vec![],
1217 accepted_proposal_preimages: vec![],
1218 results: vec![
1219 ActionResult {
1220 action_id: "a1".to_string(),
1221 status: ActionStatus::Succeeded,
1222 output: None,
1223 error: None,
1224 terminal: false,
1225 state_changes: HashMap::new(),
1226 rolled_back: false,
1227 duration_ms: None,
1228 timestamp: Utc::now(),
1229 },
1230 ActionResult {
1231 action_id: "a2".to_string(),
1232 status: ActionStatus::Succeeded,
1233 output: None,
1234 error: None,
1235 terminal: false,
1236 state_changes: HashMap::new(),
1237 rolled_back: false,
1238 duration_ms: None,
1239 timestamp: Utc::now(),
1240 },
1241 ],
1242 cost: CostSummary::default(),
1243 };
1244 assert!(pr.all_succeeded());
1245 }
1246
1247 #[test]
1248 fn proposal_result_not_all_succeeded() {
1249 let pr = ProposalResult {
1250 proposal_id: "p1".to_string(),
1251 original_proposal_id: "p1".to_string(),
1252 final_proposal: None,
1253 replan_lineage: vec![],
1254 accepted_proposal_preimages: vec![],
1255 results: vec![
1256 ActionResult {
1257 action_id: "a1".to_string(),
1258 status: ActionStatus::Succeeded,
1259 output: None,
1260 error: None,
1261 terminal: false,
1262 state_changes: HashMap::new(),
1263 rolled_back: false,
1264 duration_ms: None,
1265 timestamp: Utc::now(),
1266 },
1267 ActionResult {
1268 action_id: "a2".to_string(),
1269 status: ActionStatus::Failed,
1270 output: None,
1271 error: Some("boom".to_string()),
1272 terminal: false,
1273 state_changes: HashMap::new(),
1274 rolled_back: false,
1275 duration_ms: None,
1276 timestamp: Utc::now(),
1277 },
1278 ],
1279 cost: CostSummary::default(),
1280 };
1281 assert!(!pr.all_succeeded());
1282 }
1283
1284 #[test]
1285 fn cost_summary_default_is_zero() {
1286 let cost = CostSummary::default();
1287 assert_eq!(cost.tool_calls, 0);
1288 assert_eq!(cost.actions_executed, 0);
1289 assert_eq!(cost.actions_rejected, 0);
1290 assert_eq!(cost.actions_skipped, 0);
1291 assert_eq!(cost.total_duration_ms, 0.0);
1292 assert_eq!(cost.retries, 0);
1293 }
1294
1295 #[test]
1297 fn cost_summary_deserializes_without_actions_rejected() {
1298 let legacy = r#"{"tool_calls":1,"actions_executed":2,"actions_skipped":0,
1299 "total_duration_ms":5.0,"retries":0}"#;
1300 let cost: CostSummary = serde_json::from_str(legacy).unwrap();
1301 assert_eq!(cost.actions_executed, 2);
1302 assert_eq!(cost.actions_rejected, 0);
1303 }
1304
1305 #[test]
1306 fn cost_summary_serde_roundtrip() {
1307 let cost = CostSummary {
1308 tool_calls: 3,
1309 actions_executed: 5,
1310 actions_rejected: 2,
1311 actions_skipped: 1,
1312 total_duration_ms: 42.5,
1313 retries: 2,
1314 };
1315 let json = serde_json::to_string(&cost).unwrap();
1316 let roundtripped: CostSummary = serde_json::from_str(&json).unwrap();
1317 assert_eq!(cost, roundtripped);
1318 }
1319
1320 #[test]
1321 fn proposal_result_deserializes_without_cost() {
1322 let json = r#"{"proposal_id": "p1", "results": []}"#;
1324 let pr: ProposalResult = serde_json::from_str(json).unwrap();
1325 assert_eq!(pr.cost, CostSummary::default());
1326 assert_eq!(pr.original_proposal_id, "p1");
1327 assert_eq!(pr.final_proposal, None);
1328 assert!(pr.replan_lineage.is_empty());
1329 assert!(pr.accepted_proposal_preimages.is_empty());
1330 }
1331
1332 #[test]
1333 fn proposal_result_lineage_has_exact_tagged_wire_shape() {
1334 let original_digest = "a".repeat(64);
1335 let candidate_digest = "b".repeat(64);
1336 let accepted_digest = "c".repeat(64);
1337 let final_proposal = ActionProposal {
1338 id: "accepted-replan".to_string(),
1339 source: "replanner".to_string(),
1340 actions: vec![],
1341 timestamp: Utc::now(),
1342 context: HashMap::new(),
1343 };
1344 let pr = ProposalResult {
1345 proposal_id: "accepted-replan".to_string(),
1346 original_proposal_id: "original".to_string(),
1347 final_proposal: Some(final_proposal.clone()),
1348 replan_lineage: vec![
1349 ProposalLineageEntry {
1350 generation: 0,
1351 proposal_id: "original".to_string(),
1352 proposal_digest: Some(original_digest.clone()),
1353 status: ProposalLineageStatus::Accepted,
1354 rejection_reason: None,
1355 },
1356 ProposalLineageEntry {
1357 generation: 1,
1358 proposal_id: "duplicate-candidate".to_string(),
1359 proposal_digest: Some(candidate_digest.clone()),
1360 status: ProposalLineageStatus::Rejected,
1361 rejection_reason: Some("duplicate action id 'same'".to_string()),
1362 },
1363 ProposalLineageEntry {
1364 generation: 2,
1365 proposal_id: "accepted-replan".to_string(),
1366 proposal_digest: Some(accepted_digest.clone()),
1367 status: ProposalLineageStatus::Accepted,
1368 rejection_reason: None,
1369 },
1370 ],
1371 accepted_proposal_preimages: vec![AcceptedProposalPreimage {
1372 generation: 2,
1373 proposal_digest: accepted_digest.clone(),
1374 proposal: final_proposal.clone(),
1375 }],
1376 results: vec![],
1377 cost: CostSummary::default(),
1378 };
1379 let value = serde_json::to_value(&pr).unwrap();
1380 assert_eq!(value["proposal_id"], "accepted-replan");
1381 assert_eq!(value["original_proposal_id"], "original");
1382 assert_eq!(value["final_proposal"], serde_json::json!(final_proposal));
1383 assert_eq!(
1384 value["replan_lineage"],
1385 serde_json::json!([
1386 {
1387 "generation": 0,
1388 "proposal_id": "original",
1389 "proposal_digest": original_digest,
1390 "status": "accepted"
1391 },
1392 {
1393 "generation": 1,
1394 "proposal_id": "duplicate-candidate",
1395 "proposal_digest": candidate_digest,
1396 "status": "rejected",
1397 "rejection_reason": "duplicate action id 'same'"
1398 },
1399 {
1400 "generation": 2,
1401 "proposal_id": "accepted-replan",
1402 "proposal_digest": accepted_digest,
1403 "status": "accepted"
1404 }
1405 ])
1406 );
1407 assert_eq!(
1408 value["accepted_proposal_preimages"],
1409 serde_json::json!([{
1410 "generation": 2,
1411 "proposal_digest": accepted_digest,
1412 "proposal": final_proposal,
1413 }])
1414 );
1415 }
1416
1417 #[test]
1418 fn proposal_lineage_rejects_missing_or_noncanonical_accepted_digest() {
1419 for digest in [Value::Null, Value::from("A".repeat(64)), Value::from("abc")] {
1420 let value = serde_json::json!({
1421 "generation": 0,
1422 "proposal_id": "p",
1423 "proposal_digest": digest,
1424 "status": "accepted"
1425 });
1426 let error = serde_json::from_value::<ProposalLineageEntry>(value).unwrap_err();
1427 assert!(
1428 error
1429 .to_string()
1430 .contains("accepted proposal lineage requires a lowercase JCS SHA-256 digest"),
1431 "unexpected error: {error}"
1432 );
1433 }
1434 }
1435
1436 #[test]
1437 fn rejected_lineage_without_digest_requires_exact_reason() {
1438 let valid: ProposalLineageEntry = serde_json::from_value(serde_json::json!({
1439 "generation": 1,
1440 "proposal_id": "not-jcs",
1441 "status": "rejected",
1442 "rejection_reason": "RFC 8785 canonicalization failed: number is outside the I-JSON safe integer range"
1443 }))
1444 .unwrap();
1445 assert!(valid.proposal_digest.is_none());
1446
1447 let error = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1448 "generation": 1,
1449 "proposal_id": "not-jcs",
1450 "status": "rejected"
1451 }))
1452 .unwrap_err();
1453 assert!(error
1454 .to_string()
1455 .contains("rejected proposal lineage requires an exact rejection reason"));
1456
1457 let wrong_reason = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1458 "generation": 1,
1459 "proposal_id": "not-jcs",
1460 "status": "rejected",
1461 "rejection_reason": "duplicate action id"
1462 }))
1463 .unwrap_err();
1464 assert!(wrong_reason
1465 .to_string()
1466 .contains("undigested rejected proposal lineage must identify a JCS/I-JSON failure"));
1467 }
1468
1469 #[test]
1470 fn tool_source_kind_uses_the_stable_wire_vocabulary() {
1471 let cases = [
1472 (ToolSourceKind::Builtin, "builtin"),
1473 (ToolSourceKind::UserDefined, "user_defined"),
1474 (ToolSourceKind::Subprocess, "subprocess"),
1475 (ToolSourceKind::Mcp, "mcp"),
1476 ];
1477 for (source, expected) in cases {
1478 assert_eq!(source.as_str(), expected);
1479 assert_eq!(
1480 serde_json::to_value(source).unwrap(),
1481 serde_json::json!(expected)
1482 );
1483 }
1484 }
1485
1486 #[test]
1487 fn legacy_tool_schema_defaults_to_user_defined_source() {
1488 let schema: ToolSchema = serde_json::from_value(serde_json::json!({
1489 "name": "legacy",
1490 "parameters": {}
1491 }))
1492 .unwrap();
1493 assert_eq!(schema.source, ToolSourceKind::UserDefined);
1494 assert_eq!(
1495 serde_json::to_value(schema).unwrap()["source"],
1496 "user_defined"
1497 );
1498 }
1499
1500 #[test]
1501 fn deserialize_from_python_compatible_json() {
1502 let json = r#"{
1504 "id": "test123",
1505 "type": "tool_call",
1506 "tool": "add",
1507 "parameters": {"a": 1, "b": 2},
1508 "preconditions": [],
1509 "expected_effects": {"sum": 3},
1510 "state_dependencies": [],
1511 "idempotent": true,
1512 "max_retries": 3,
1513 "failure_behavior": "retry",
1514 "timeout_ms": 5000,
1515 "metadata": {}
1516 }"#;
1517
1518 let action: Action = serde_json::from_str(json).unwrap();
1519 assert_eq!(action.id, "test123");
1520 assert_eq!(action.action_type, ActionType::ToolCall);
1521 assert_eq!(action.tool, Some("add".to_string()));
1522 assert!(action.idempotent);
1523 assert_eq!(action.failure_behavior, FailureBehavior::Retry);
1524 assert_eq!(action.timeout_ms, Some(5000));
1525 }
1526
1527 #[test]
1533 fn action_deserializes_without_reversibility_fields() {
1534 let legacy = r#"{
1535 "id": "legacy1",
1536 "type": "tool_call",
1537 "tool": "send_email",
1538 "parameters": {"to": "a@b.c"},
1539 "preconditions": [],
1540 "expected_effects": {},
1541 "state_dependencies": [],
1542 "idempotent": false,
1543 "max_retries": 3,
1544 "failure_behavior": "abort",
1545 "metadata": {}
1546 }"#;
1547
1548 let action: Action = serde_json::from_str(legacy).unwrap();
1549 assert_eq!(action.id, "legacy1");
1550 assert_eq!(action.reversibility, Reversibility::Irreversible);
1551 assert_eq!(action.compensation, None);
1552 assert!(!action.missing_required_compensation());
1555 }
1556
1557 #[test]
1560 fn proposal_deserializes_without_reversibility_fields() {
1561 let legacy = r#"{
1562 "id": "prop-legacy",
1563 "source": "python",
1564 "actions": [
1565 {"id": "a1", "type": "state_write", "parameters": {"key": "x", "value": 1}},
1566 {"id": "a2", "type": "tool_call", "tool": "add", "parameters": {}}
1567 ],
1568 "context": {}
1569 }"#;
1570
1571 let proposal: ActionProposal = serde_json::from_str(legacy).unwrap();
1572 assert_eq!(proposal.actions.len(), 2);
1573 for action in &proposal.actions {
1574 assert_eq!(action.reversibility, Reversibility::Irreversible);
1575 assert!(action.compensation.is_none());
1576 }
1577 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1578 }
1579
1580 #[test]
1584 fn absent_compensation_is_omitted_from_the_wire_form() {
1585 let mut action: Action =
1586 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"noop"}"#).unwrap();
1587 action.reversibility = Reversibility::Reversible;
1588
1589 let json = serde_json::to_value(&action).unwrap();
1590 assert_eq!(json["reversibility"], "reversible");
1591 assert!(
1592 json.get("compensation").is_none(),
1593 "compensation should be skipped when None, got {json}"
1594 );
1595 }
1596
1597 #[test]
1598 fn compensable_without_compensation_is_flagged() {
1599 let mut action: Action =
1600 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"db.insert"}"#).unwrap();
1601
1602 action.reversibility = Reversibility::Compensable;
1603 assert!(action.missing_required_compensation());
1604
1605 action.compensation = Some(Compensation::ActionRef {
1606 action_id: "undo-a1".to_string(),
1607 });
1608 assert!(!action.missing_required_compensation());
1609
1610 action.reversibility = Reversibility::Reversible;
1613 assert!(!action.missing_required_compensation());
1614 }
1615
1616 #[test]
1617 fn rollback_contract_is_the_worst_action_in_the_batch() {
1618 let mut proposal: ActionProposal = serde_json::from_str(
1619 r#"{"id":"p1","source":"test","actions":[
1620 {"id":"a1","type":"state_write","parameters":{"key":"x"},"reversibility":"reversible"},
1621 {"id":"a2","type":"tool_call","tool":"db.insert","reversibility":"compensable",
1622 "compensation":{"type":"tool","tool":"db.delete","parameters":{"id":1}}}
1623 ]}"#,
1624 )
1625 .unwrap();
1626 assert_eq!(proposal.rollback_contract(), Reversibility::Compensable);
1627
1628 proposal.actions[0].reversibility = Reversibility::Irreversible;
1629 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1630
1631 proposal.actions.clear();
1634 assert_eq!(proposal.rollback_contract(), Reversibility::Reversible);
1635 }
1636}