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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
563pub struct ToolRateLimit {
564 pub max_calls: u32,
565 pub interval_secs: f64,
566}
567
568#[derive(
574 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
575)]
576#[serde(rename_all = "snake_case")]
577pub enum ToolSourceKind {
578 Builtin,
579 #[default]
580 UserDefined,
581 Subprocess,
582 Mcp,
583}
584
585impl ToolSourceKind {
586 pub const fn as_str(self) -> &'static str {
587 match self {
588 Self::Builtin => "builtin",
589 Self::UserDefined => "user_defined",
590 Self::Subprocess => "subprocess",
591 Self::Mcp => "mcp",
592 }
593 }
594}
595
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
601pub struct ToolSchema {
602 pub name: String,
603 #[serde(default)]
606 pub source: ToolSourceKind,
607 #[serde(default)]
608 pub description: String,
609 #[serde(default = "default_parameters_schema")]
611 pub parameters: Value,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub returns: Option<Value>,
615 #[serde(default)]
617 pub idempotent: bool,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub cache_ttl_secs: Option<u64>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub rate_limit: Option<ToolRateLimit>,
624}
625
626fn default_parameters_schema() -> Value {
627 Value::Object(Default::default())
628}
629
630#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
632pub struct CostSummary {
633 pub tool_calls: u32,
634 pub actions_executed: u32,
641 #[serde(default)]
648 pub actions_rejected: u32,
649 pub actions_skipped: u32,
650 pub total_duration_ms: f64,
651 pub retries: u32,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct CostTarget {
663 pub target_tool_calls: u32,
665 pub target_duration_ms: f64,
667 pub target_actions: u32,
669 pub cost_weight: f64,
671}
672
673impl Default for CostTarget {
674 fn default() -> Self {
675 Self {
676 target_tool_calls: 5,
677 target_duration_ms: 5000.0,
678 target_actions: 10,
679 cost_weight: 0.2,
680 }
681 }
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum ProposalLineageStatus {
688 Accepted,
689 Rejected,
690}
691
692#[derive(Debug, Clone, PartialEq, Eq)]
696pub struct ProposalLineageEntry {
697 pub generation: u32,
698 pub proposal_id: String,
699 pub proposal_digest: Option<String>,
700 pub status: ProposalLineageStatus,
701 pub rejection_reason: Option<String>,
702}
703
704impl ProposalLineageEntry {
705 fn validate(&self) -> Result<(), String> {
706 let digest_is_lowercase_sha256 = |digest: &str| {
707 digest.len() == 64
708 && digest
709 .bytes()
710 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
711 };
712
713 match self.status {
714 ProposalLineageStatus::Accepted => {
715 let digest = self.proposal_digest.as_deref().ok_or_else(|| {
716 "accepted proposal lineage requires a lowercase JCS SHA-256 digest".to_string()
717 })?;
718 if !digest_is_lowercase_sha256(digest) {
719 return Err(
720 "accepted proposal lineage requires a lowercase JCS SHA-256 digest"
721 .to_string(),
722 );
723 }
724 if self.rejection_reason.is_some() {
725 return Err(
726 "accepted proposal lineage cannot include a rejection reason".to_string(),
727 );
728 }
729 }
730 ProposalLineageStatus::Rejected => {
731 if let Some(digest) = self.proposal_digest.as_deref() {
732 if !digest_is_lowercase_sha256(digest) {
733 return Err(
734 "rejected proposal lineage digest must be lowercase JCS SHA-256"
735 .to_string(),
736 );
737 }
738 }
739 let reason = self
740 .rejection_reason
741 .as_deref()
742 .filter(|reason| !reason.trim().is_empty())
743 .ok_or_else(|| {
744 "rejected proposal lineage requires an exact rejection reason".to_string()
745 })?;
746 if self.proposal_digest.is_none()
747 && !reason.starts_with("RFC 8785 canonicalization failed")
748 && !reason.starts_with("proposal serialization failed")
749 {
750 return Err(
751 "undigested rejected proposal lineage must identify a JCS/I-JSON failure"
752 .to_string(),
753 );
754 }
755 }
756 }
757 Ok(())
758 }
759}
760
761impl Serialize for ProposalLineageEntry {
762 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
763 where
764 S: serde::Serializer,
765 {
766 use serde::ser::{Error as _, SerializeStruct};
767 self.validate().map_err(S::Error::custom)?;
768 let mut state = serializer.serialize_struct("ProposalLineageEntry", 5)?;
769 state.serialize_field("generation", &self.generation)?;
770 state.serialize_field("proposal_id", &self.proposal_id)?;
771 if let Some(digest) = &self.proposal_digest {
772 state.serialize_field("proposal_digest", digest)?;
773 }
774 state.serialize_field("status", &self.status)?;
775 if let Some(reason) = &self.rejection_reason {
776 state.serialize_field("rejection_reason", reason)?;
777 }
778 state.end()
779 }
780}
781
782impl<'de> Deserialize<'de> for ProposalLineageEntry {
783 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
784 where
785 D: serde::Deserializer<'de>,
786 {
787 #[derive(Deserialize)]
788 struct Wire {
789 generation: u32,
790 proposal_id: String,
791 #[serde(default)]
792 proposal_digest: Option<String>,
793 status: ProposalLineageStatus,
794 #[serde(default)]
795 rejection_reason: Option<String>,
796 }
797
798 let wire = Wire::deserialize(deserializer)?;
799 let entry = Self {
800 generation: wire.generation,
801 proposal_id: wire.proposal_id,
802 proposal_digest: wire.proposal_digest,
803 status: wire.status,
804 rejection_reason: wire.rejection_reason,
805 };
806 entry.validate().map_err(serde::de::Error::custom)?;
807 Ok(entry)
808 }
809}
810
811#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
816pub struct AcceptedProposalPreimage {
817 pub generation: u32,
818 pub proposal_digest: String,
819 pub proposal: ActionProposal,
820}
821
822#[derive(Debug, Clone, PartialEq, Serialize)]
824pub struct ProposalResult {
825 pub proposal_id: String,
828
829 pub original_proposal_id: String,
831
832 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub final_proposal: Option<ActionProposal>,
839
840 #[serde(default, skip_serializing_if = "Vec::is_empty")]
842 pub replan_lineage: Vec<ProposalLineageEntry>,
843
844 #[serde(default, skip_serializing_if = "Vec::is_empty")]
848 pub accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
849
850 #[serde(default)]
851 pub results: Vec<ActionResult>,
852
853 #[serde(default)]
854 pub cost: CostSummary,
855}
856
857impl<'de> Deserialize<'de> for ProposalResult {
858 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
859 where
860 D: serde::Deserializer<'de>,
861 {
862 #[derive(Deserialize)]
863 struct Wire {
864 proposal_id: String,
865 #[serde(default)]
866 original_proposal_id: Option<String>,
867 #[serde(default)]
868 final_proposal: Option<ActionProposal>,
869 #[serde(default)]
870 replan_lineage: Vec<ProposalLineageEntry>,
871 #[serde(default)]
872 accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
873 #[serde(default)]
874 results: Vec<ActionResult>,
875 #[serde(default)]
876 cost: CostSummary,
877 }
878
879 let wire = Wire::deserialize(deserializer)?;
880 let original_proposal_id = wire
881 .original_proposal_id
882 .unwrap_or_else(|| wire.proposal_id.clone());
883 Ok(Self {
884 proposal_id: wire.proposal_id,
885 original_proposal_id,
886 final_proposal: wire.final_proposal,
887 replan_lineage: wire.replan_lineage,
888 accepted_proposal_preimages: wire.accepted_proposal_preimages,
889 results: wire.results,
890 cost: wire.cost,
891 })
892 }
893}
894
895impl ProposalResult {
896 pub fn new(
897 proposal_id: impl Into<String>,
898 results: Vec<ActionResult>,
899 cost: CostSummary,
900 ) -> Self {
901 let proposal_id = proposal_id.into();
902 Self {
903 original_proposal_id: proposal_id.clone(),
904 proposal_id,
905 final_proposal: None,
906 replan_lineage: Vec::new(),
907 accepted_proposal_preimages: Vec::new(),
908 results,
909 cost,
910 }
911 }
912
913 pub fn for_proposal(
917 proposal: &ActionProposal,
918 results: Vec<ActionResult>,
919 cost: CostSummary,
920 ) -> Self {
921 Self {
922 proposal_id: proposal.id.clone(),
923 original_proposal_id: proposal.id.clone(),
924 final_proposal: Some(proposal.clone()),
925 replan_lineage: Vec::new(),
926 accepted_proposal_preimages: Vec::new(),
927 results,
928 cost,
929 }
930 }
931
932 pub fn all_succeeded(&self) -> bool {
933 self.results
934 .iter()
935 .all(|r| r.status == ActionStatus::Succeeded)
936 }
937
938 pub fn summary(&self) -> HashMap<ActionStatus, usize> {
939 let mut counts = HashMap::new();
940 for r in &self.results {
941 *counts.entry(r.status.clone()).or_insert(0) += 1;
942 }
943 counts
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use pretty_assertions::assert_eq;
951
952 #[test]
953 fn constructors_fill_defaults_and_leave_the_wire_unchanged() {
954 let a = Action::new(ActionType::ToolCall);
960 assert_eq!(a.action_type, ActionType::ToolCall);
961 assert!(a.tool.is_none());
962 assert!(a.parameters.is_empty());
963 assert!(a.preconditions.is_empty());
964 assert!(a.expected_effects.is_empty());
965 assert!(a.state_dependencies.is_empty());
966 assert!(a.read_set.is_empty());
967 assert!(a.write_set.is_empty());
968 assert!(a.assumptions.is_empty());
969 assert_eq!(a.reversibility, Reversibility::default());
970 assert!(a.compensation.is_none());
971 assert!(!a.idempotent);
972 assert_eq!(a.max_retries, default_max_retries());
973 assert_eq!(a.failure_behavior, FailureBehavior::Abort);
974 assert!(a.timeout_ms.is_none());
975 assert!(a.metadata.is_empty());
976 assert_eq!(a.id.len(), 12, "id is a generated short id");
977
978 assert_eq!(Action::tool_call("deploy").tool.as_deref(), Some("deploy"));
980
981 let sw = Action::state_write("k", Value::from(1));
982 assert_eq!(sw.action_type, ActionType::StateWrite);
983 assert_eq!(sw.parameters["key"], Value::from("k"));
984 assert_eq!(sw.parameters["value"], Value::from(1));
985 assert_eq!(sw.expected_effects["k"], Value::from(1));
989 assert_eq!(sw.effective_write_set(), vec!["k".to_string()]);
990
991 let sr = Action::state_read("k");
992 assert_eq!(sr.action_type, ActionType::StateRead);
993 assert_eq!(sr.parameters["key"], Value::from("k"));
994
995 let c = Action::tool_call("t")
997 .with_id("fixed")
998 .with_param("p", Value::from(2));
999 assert_eq!(c.id, "fixed");
1000 assert_eq!(c.parameters["p"], Value::from(2));
1001 }
1002
1003 #[test]
1004 fn action_type_serializes_snake_case() {
1005 assert_eq!(
1006 serde_json::to_string(&ActionType::ToolCall).unwrap(),
1007 "\"tool_call\""
1008 );
1009 assert_eq!(
1010 serde_json::to_string(&ActionType::StateWrite).unwrap(),
1011 "\"state_write\""
1012 );
1013 }
1014
1015 #[test]
1016 fn failure_behavior_serializes_snake_case() {
1017 assert_eq!(
1018 serde_json::to_string(&FailureBehavior::Abort).unwrap(),
1019 "\"abort\""
1020 );
1021 assert_eq!(
1022 serde_json::to_string(&FailureBehavior::Retry).unwrap(),
1023 "\"retry\""
1024 );
1025 }
1026
1027 #[test]
1028 fn action_roundtrip_json() {
1029 let action = Action {
1030 id: "abc123".to_string(),
1031 action_type: ActionType::ToolCall,
1032 tool: Some("add".to_string()),
1033 parameters: [
1034 ("a".to_string(), Value::from(1)),
1035 ("b".to_string(), Value::from(2)),
1036 ]
1037 .into(),
1038 preconditions: vec![Precondition {
1039 key: "auth".to_string(),
1040 operator: "eq".to_string(),
1041 value: Value::Bool(true),
1042 description: String::new(),
1043 }],
1044 expected_effects: [("sum".to_string(), Value::from(3))].into(),
1045 state_dependencies: vec!["auth".to_string()],
1046 read_set: vec![],
1047 write_set: vec![],
1048 assumptions: vec![],
1049 invocation_mode: Default::default(),
1050 reversibility: Reversibility::Compensable,
1051 compensation: Some(Compensation::Tool {
1052 tool: "subtract".to_string(),
1053 parameters: [("sum".to_string(), Value::from(3))].into(),
1054 }),
1055 idempotent: true,
1056 max_retries: 3,
1057 failure_behavior: FailureBehavior::Retry,
1058 timeout_ms: Some(5000),
1059 metadata: HashMap::new(),
1060 };
1061
1062 let json = serde_json::to_string_pretty(&action).unwrap();
1063 let roundtripped: Action = serde_json::from_str(&json).unwrap();
1064
1065 assert_eq!(action.id, roundtripped.id);
1066 assert_eq!(action.action_type, roundtripped.action_type);
1067 assert_eq!(action.tool, roundtripped.tool);
1068 assert_eq!(action.idempotent, roundtripped.idempotent);
1069 assert_eq!(action.failure_behavior, roundtripped.failure_behavior);
1070 assert_eq!(action.timeout_ms, roundtripped.timeout_ms);
1071 assert_eq!(action.reversibility, roundtripped.reversibility);
1072 assert_eq!(action.compensation, roundtripped.compensation);
1073 assert_eq!(action, roundtripped);
1074 }
1075
1076 #[test]
1077 fn proposal_roundtrip_json() {
1078 let proposal = ActionProposal {
1079 id: "prop1".to_string(),
1080 source: "test".to_string(),
1081 actions: vec![Action {
1082 id: "a1".to_string(),
1083 action_type: ActionType::StateWrite,
1084 tool: None,
1085 parameters: [
1086 ("key".to_string(), Value::from("x")),
1087 ("value".to_string(), Value::from(42)),
1088 ]
1089 .into(),
1090 preconditions: vec![],
1091 expected_effects: HashMap::new(),
1092 state_dependencies: vec![],
1093 read_set: vec![],
1094 write_set: vec![],
1095 assumptions: vec![],
1096 invocation_mode: Default::default(),
1097 reversibility: Reversibility::Reversible,
1098 compensation: None,
1099 idempotent: false,
1100 max_retries: 3,
1101 failure_behavior: FailureBehavior::Abort,
1102 timeout_ms: None,
1103 metadata: HashMap::new(),
1104 }],
1105 timestamp: Utc::now(),
1106 context: HashMap::new(),
1107 };
1108
1109 let json = serde_json::to_string(&proposal).unwrap();
1110 let roundtripped: ActionProposal = serde_json::from_str(&json).unwrap();
1111
1112 assert_eq!(proposal.id, roundtripped.id);
1113 assert_eq!(proposal.source, roundtripped.source);
1114 assert_eq!(proposal.actions.len(), roundtripped.actions.len());
1115 }
1116
1117 #[test]
1118 fn state_mutation_round_trips_the_stable_wire_shape() {
1119 let set = StateMutation::Set {
1120 value: Value::from(42),
1121 };
1122 let set_wire = set.clone().encode();
1123 assert_eq!(set_wire, serde_json::json!({"op": "set", "value": 42}));
1124 assert_eq!(StateMutation::decode(&set_wire).unwrap(), set);
1125
1126 let delete = StateMutation::Delete;
1127 let delete_wire = delete.clone().encode();
1128 assert_eq!(delete_wire, serde_json::json!({"op": "delete"}));
1129 assert_eq!(StateMutation::decode(&delete_wire).unwrap(), delete);
1130
1131 assert_eq!(
1132 StateMutation::decode(&serde_json::json!({"op": "set"})).unwrap(),
1133 StateMutation::Set { value: Value::Null },
1134 "a missing set value retains the previous set-to-null interpretation"
1135 );
1136 assert!(StateMutation::decode(&serde_json::json!({"legacy": true})).is_err());
1137 }
1138
1139 #[test]
1140 fn action_result_serializes() {
1141 let result = ActionResult {
1142 action_id: "a1".to_string(),
1143 status: ActionStatus::Succeeded,
1144 output: Some(Value::from(42)),
1145 error: None,
1146 terminal: false,
1147 state_changes: HashMap::new(),
1148 rolled_back: false,
1149 duration_ms: Some(1.5),
1150 timestamp: Utc::now(),
1151 };
1152
1153 let json = serde_json::to_string(&result).unwrap();
1154 assert!(json.contains("\"succeeded\""));
1155 assert!(!json.contains("\"terminal\""));
1156 assert!(
1157 !json.contains("rolled_back"),
1158 "the additive false marker stays absent on the wire"
1159 );
1160
1161 let mut rolled_back = result;
1162 rolled_back.rolled_back = true;
1163 let value = serde_json::to_value(&rolled_back).unwrap();
1164 assert_eq!(value["rolled_back"], true);
1165
1166 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1167 "action_id": "legacy",
1168 "status": "succeeded",
1169 "state_changes": {}
1170 }))
1171 .unwrap();
1172 assert!(!legacy.rolled_back);
1173 }
1174
1175 #[test]
1176 fn terminal_tool_failure_is_explicit_and_roundtrips_on_action_results() {
1177 let ordinary = ToolFailure::from("ordinary failure");
1178 assert_eq!(ordinary.classification, ToolFailureClassification::Ordinary);
1179 assert!(!ordinary.is_terminal());
1180
1181 let failure = ToolFailure::terminal("stop now");
1182 assert_eq!(failure.classification, ToolFailureClassification::Terminal);
1183 assert!(failure.is_terminal());
1184 assert_eq!(
1185 serde_json::to_value(&failure).unwrap(),
1186 serde_json::json!({
1187 "message": "stop now",
1188 "classification": "terminal"
1189 })
1190 );
1191
1192 let result: ActionResult = serde_json::from_value(serde_json::json!({
1193 "action_id": "a1",
1194 "status": "failed",
1195 "error": "stop now",
1196 "terminal": true
1197 }))
1198 .unwrap();
1199 assert!(result.terminal);
1200 assert!(!result.rolled_back);
1201
1202 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1203 "action_id": "a2",
1204 "status": "failed",
1205 "error": "ordinary failure"
1206 }))
1207 .unwrap();
1208 assert!(!legacy.terminal);
1209 assert!(!legacy.rolled_back);
1210 }
1211
1212 #[test]
1213 fn proposal_result_all_succeeded() {
1214 let pr = ProposalResult {
1215 proposal_id: "p1".to_string(),
1216 original_proposal_id: "p1".to_string(),
1217 final_proposal: None,
1218 replan_lineage: vec![],
1219 accepted_proposal_preimages: vec![],
1220 results: vec![
1221 ActionResult {
1222 action_id: "a1".to_string(),
1223 status: ActionStatus::Succeeded,
1224 output: None,
1225 error: None,
1226 terminal: false,
1227 state_changes: HashMap::new(),
1228 rolled_back: false,
1229 duration_ms: None,
1230 timestamp: Utc::now(),
1231 },
1232 ActionResult {
1233 action_id: "a2".to_string(),
1234 status: ActionStatus::Succeeded,
1235 output: None,
1236 error: None,
1237 terminal: false,
1238 state_changes: HashMap::new(),
1239 rolled_back: false,
1240 duration_ms: None,
1241 timestamp: Utc::now(),
1242 },
1243 ],
1244 cost: CostSummary::default(),
1245 };
1246 assert!(pr.all_succeeded());
1247 }
1248
1249 #[test]
1250 fn proposal_result_not_all_succeeded() {
1251 let pr = ProposalResult {
1252 proposal_id: "p1".to_string(),
1253 original_proposal_id: "p1".to_string(),
1254 final_proposal: None,
1255 replan_lineage: vec![],
1256 accepted_proposal_preimages: vec![],
1257 results: vec![
1258 ActionResult {
1259 action_id: "a1".to_string(),
1260 status: ActionStatus::Succeeded,
1261 output: None,
1262 error: None,
1263 terminal: false,
1264 state_changes: HashMap::new(),
1265 rolled_back: false,
1266 duration_ms: None,
1267 timestamp: Utc::now(),
1268 },
1269 ActionResult {
1270 action_id: "a2".to_string(),
1271 status: ActionStatus::Failed,
1272 output: None,
1273 error: Some("boom".to_string()),
1274 terminal: false,
1275 state_changes: HashMap::new(),
1276 rolled_back: false,
1277 duration_ms: None,
1278 timestamp: Utc::now(),
1279 },
1280 ],
1281 cost: CostSummary::default(),
1282 };
1283 assert!(!pr.all_succeeded());
1284 }
1285
1286 #[test]
1287 fn cost_summary_default_is_zero() {
1288 let cost = CostSummary::default();
1289 assert_eq!(cost.tool_calls, 0);
1290 assert_eq!(cost.actions_executed, 0);
1291 assert_eq!(cost.actions_rejected, 0);
1292 assert_eq!(cost.actions_skipped, 0);
1293 assert_eq!(cost.total_duration_ms, 0.0);
1294 assert_eq!(cost.retries, 0);
1295 }
1296
1297 #[test]
1299 fn cost_summary_deserializes_without_actions_rejected() {
1300 let legacy = r#"{"tool_calls":1,"actions_executed":2,"actions_skipped":0,
1301 "total_duration_ms":5.0,"retries":0}"#;
1302 let cost: CostSummary = serde_json::from_str(legacy).unwrap();
1303 assert_eq!(cost.actions_executed, 2);
1304 assert_eq!(cost.actions_rejected, 0);
1305 }
1306
1307 #[test]
1308 fn cost_summary_serde_roundtrip() {
1309 let cost = CostSummary {
1310 tool_calls: 3,
1311 actions_executed: 5,
1312 actions_rejected: 2,
1313 actions_skipped: 1,
1314 total_duration_ms: 42.5,
1315 retries: 2,
1316 };
1317 let json = serde_json::to_string(&cost).unwrap();
1318 let roundtripped: CostSummary = serde_json::from_str(&json).unwrap();
1319 assert_eq!(cost, roundtripped);
1320 }
1321
1322 #[test]
1323 fn proposal_result_deserializes_without_cost() {
1324 let json = r#"{"proposal_id": "p1", "results": []}"#;
1326 let pr: ProposalResult = serde_json::from_str(json).unwrap();
1327 assert_eq!(pr.cost, CostSummary::default());
1328 assert_eq!(pr.original_proposal_id, "p1");
1329 assert_eq!(pr.final_proposal, None);
1330 assert!(pr.replan_lineage.is_empty());
1331 assert!(pr.accepted_proposal_preimages.is_empty());
1332 }
1333
1334 #[test]
1335 fn proposal_result_lineage_has_exact_tagged_wire_shape() {
1336 let original_digest = "a".repeat(64);
1337 let candidate_digest = "b".repeat(64);
1338 let accepted_digest = "c".repeat(64);
1339 let final_proposal = ActionProposal {
1340 id: "accepted-replan".to_string(),
1341 source: "replanner".to_string(),
1342 actions: vec![],
1343 timestamp: Utc::now(),
1344 context: HashMap::new(),
1345 };
1346 let pr = ProposalResult {
1347 proposal_id: "accepted-replan".to_string(),
1348 original_proposal_id: "original".to_string(),
1349 final_proposal: Some(final_proposal.clone()),
1350 replan_lineage: vec![
1351 ProposalLineageEntry {
1352 generation: 0,
1353 proposal_id: "original".to_string(),
1354 proposal_digest: Some(original_digest.clone()),
1355 status: ProposalLineageStatus::Accepted,
1356 rejection_reason: None,
1357 },
1358 ProposalLineageEntry {
1359 generation: 1,
1360 proposal_id: "duplicate-candidate".to_string(),
1361 proposal_digest: Some(candidate_digest.clone()),
1362 status: ProposalLineageStatus::Rejected,
1363 rejection_reason: Some("duplicate action id 'same'".to_string()),
1364 },
1365 ProposalLineageEntry {
1366 generation: 2,
1367 proposal_id: "accepted-replan".to_string(),
1368 proposal_digest: Some(accepted_digest.clone()),
1369 status: ProposalLineageStatus::Accepted,
1370 rejection_reason: None,
1371 },
1372 ],
1373 accepted_proposal_preimages: vec![AcceptedProposalPreimage {
1374 generation: 2,
1375 proposal_digest: accepted_digest.clone(),
1376 proposal: final_proposal.clone(),
1377 }],
1378 results: vec![],
1379 cost: CostSummary::default(),
1380 };
1381 let value = serde_json::to_value(&pr).unwrap();
1382 assert_eq!(value["proposal_id"], "accepted-replan");
1383 assert_eq!(value["original_proposal_id"], "original");
1384 assert_eq!(value["final_proposal"], serde_json::json!(final_proposal));
1385 assert_eq!(
1386 value["replan_lineage"],
1387 serde_json::json!([
1388 {
1389 "generation": 0,
1390 "proposal_id": "original",
1391 "proposal_digest": original_digest,
1392 "status": "accepted"
1393 },
1394 {
1395 "generation": 1,
1396 "proposal_id": "duplicate-candidate",
1397 "proposal_digest": candidate_digest,
1398 "status": "rejected",
1399 "rejection_reason": "duplicate action id 'same'"
1400 },
1401 {
1402 "generation": 2,
1403 "proposal_id": "accepted-replan",
1404 "proposal_digest": accepted_digest,
1405 "status": "accepted"
1406 }
1407 ])
1408 );
1409 assert_eq!(
1410 value["accepted_proposal_preimages"],
1411 serde_json::json!([{
1412 "generation": 2,
1413 "proposal_digest": accepted_digest,
1414 "proposal": final_proposal,
1415 }])
1416 );
1417 }
1418
1419 #[test]
1420 fn proposal_lineage_rejects_missing_or_noncanonical_accepted_digest() {
1421 for digest in [Value::Null, Value::from("A".repeat(64)), Value::from("abc")] {
1422 let value = serde_json::json!({
1423 "generation": 0,
1424 "proposal_id": "p",
1425 "proposal_digest": digest,
1426 "status": "accepted"
1427 });
1428 let error = serde_json::from_value::<ProposalLineageEntry>(value).unwrap_err();
1429 assert!(
1430 error
1431 .to_string()
1432 .contains("accepted proposal lineage requires a lowercase JCS SHA-256 digest"),
1433 "unexpected error: {error}"
1434 );
1435 }
1436 }
1437
1438 #[test]
1439 fn rejected_lineage_without_digest_requires_exact_reason() {
1440 let valid: ProposalLineageEntry = serde_json::from_value(serde_json::json!({
1441 "generation": 1,
1442 "proposal_id": "not-jcs",
1443 "status": "rejected",
1444 "rejection_reason": "RFC 8785 canonicalization failed: number is outside the I-JSON safe integer range"
1445 }))
1446 .unwrap();
1447 assert!(valid.proposal_digest.is_none());
1448
1449 let error = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1450 "generation": 1,
1451 "proposal_id": "not-jcs",
1452 "status": "rejected"
1453 }))
1454 .unwrap_err();
1455 assert!(error
1456 .to_string()
1457 .contains("rejected proposal lineage requires an exact rejection reason"));
1458
1459 let wrong_reason = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1460 "generation": 1,
1461 "proposal_id": "not-jcs",
1462 "status": "rejected",
1463 "rejection_reason": "duplicate action id"
1464 }))
1465 .unwrap_err();
1466 assert!(wrong_reason
1467 .to_string()
1468 .contains("undigested rejected proposal lineage must identify a JCS/I-JSON failure"));
1469 }
1470
1471 #[test]
1472 fn tool_source_kind_uses_the_stable_wire_vocabulary() {
1473 let cases = [
1474 (ToolSourceKind::Builtin, "builtin"),
1475 (ToolSourceKind::UserDefined, "user_defined"),
1476 (ToolSourceKind::Subprocess, "subprocess"),
1477 (ToolSourceKind::Mcp, "mcp"),
1478 ];
1479 for (source, expected) in cases {
1480 assert_eq!(source.as_str(), expected);
1481 assert_eq!(
1482 serde_json::to_value(source).unwrap(),
1483 serde_json::json!(expected)
1484 );
1485 }
1486 }
1487
1488 #[test]
1489 fn legacy_tool_schema_defaults_to_user_defined_source() {
1490 let schema: ToolSchema = serde_json::from_value(serde_json::json!({
1491 "name": "legacy",
1492 "parameters": {}
1493 }))
1494 .unwrap();
1495 assert_eq!(schema.source, ToolSourceKind::UserDefined);
1496 assert_eq!(
1497 serde_json::to_value(schema).unwrap()["source"],
1498 "user_defined"
1499 );
1500 }
1501
1502 #[test]
1503 fn deserialize_from_python_compatible_json() {
1504 let json = r#"{
1506 "id": "test123",
1507 "type": "tool_call",
1508 "tool": "add",
1509 "parameters": {"a": 1, "b": 2},
1510 "preconditions": [],
1511 "expected_effects": {"sum": 3},
1512 "state_dependencies": [],
1513 "idempotent": true,
1514 "max_retries": 3,
1515 "failure_behavior": "retry",
1516 "timeout_ms": 5000,
1517 "metadata": {}
1518 }"#;
1519
1520 let action: Action = serde_json::from_str(json).unwrap();
1521 assert_eq!(action.id, "test123");
1522 assert_eq!(action.action_type, ActionType::ToolCall);
1523 assert_eq!(action.tool, Some("add".to_string()));
1524 assert!(action.idempotent);
1525 assert_eq!(action.failure_behavior, FailureBehavior::Retry);
1526 assert_eq!(action.timeout_ms, Some(5000));
1527 }
1528
1529 #[test]
1535 fn action_deserializes_without_reversibility_fields() {
1536 let legacy = r#"{
1537 "id": "legacy1",
1538 "type": "tool_call",
1539 "tool": "send_email",
1540 "parameters": {"to": "a@b.c"},
1541 "preconditions": [],
1542 "expected_effects": {},
1543 "state_dependencies": [],
1544 "idempotent": false,
1545 "max_retries": 3,
1546 "failure_behavior": "abort",
1547 "metadata": {}
1548 }"#;
1549
1550 let action: Action = serde_json::from_str(legacy).unwrap();
1551 assert_eq!(action.id, "legacy1");
1552 assert_eq!(action.reversibility, Reversibility::Irreversible);
1553 assert_eq!(action.compensation, None);
1554 assert!(!action.missing_required_compensation());
1557 }
1558
1559 #[test]
1562 fn proposal_deserializes_without_reversibility_fields() {
1563 let legacy = r#"{
1564 "id": "prop-legacy",
1565 "source": "python",
1566 "actions": [
1567 {"id": "a1", "type": "state_write", "parameters": {"key": "x", "value": 1}},
1568 {"id": "a2", "type": "tool_call", "tool": "add", "parameters": {}}
1569 ],
1570 "context": {}
1571 }"#;
1572
1573 let proposal: ActionProposal = serde_json::from_str(legacy).unwrap();
1574 assert_eq!(proposal.actions.len(), 2);
1575 for action in &proposal.actions {
1576 assert_eq!(action.reversibility, Reversibility::Irreversible);
1577 assert!(action.compensation.is_none());
1578 }
1579 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1580 }
1581
1582 #[test]
1586 fn absent_compensation_is_omitted_from_the_wire_form() {
1587 let mut action: Action =
1588 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"noop"}"#).unwrap();
1589 action.reversibility = Reversibility::Reversible;
1590
1591 let json = serde_json::to_value(&action).unwrap();
1592 assert_eq!(json["reversibility"], "reversible");
1593 assert!(
1594 json.get("compensation").is_none(),
1595 "compensation should be skipped when None, got {json}"
1596 );
1597 }
1598
1599 #[test]
1600 fn compensable_without_compensation_is_flagged() {
1601 let mut action: Action =
1602 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"db.insert"}"#).unwrap();
1603
1604 action.reversibility = Reversibility::Compensable;
1605 assert!(action.missing_required_compensation());
1606
1607 action.compensation = Some(Compensation::ActionRef {
1608 action_id: "undo-a1".to_string(),
1609 });
1610 assert!(!action.missing_required_compensation());
1611
1612 action.reversibility = Reversibility::Reversible;
1615 assert!(!action.missing_required_compensation());
1616 }
1617
1618 #[test]
1619 fn rollback_contract_is_the_worst_action_in_the_batch() {
1620 let mut proposal: ActionProposal = serde_json::from_str(
1621 r#"{"id":"p1","source":"test","actions":[
1622 {"id":"a1","type":"state_write","parameters":{"key":"x"},"reversibility":"reversible"},
1623 {"id":"a2","type":"tool_call","tool":"db.insert","reversibility":"compensable",
1624 "compensation":{"type":"tool","tool":"db.delete","parameters":{"id":1}}}
1625 ]}"#,
1626 )
1627 .unwrap();
1628 assert_eq!(proposal.rollback_contract(), Reversibility::Compensable);
1629
1630 proposal.actions[0].reversibility = Reversibility::Irreversible;
1631 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1632
1633 proposal.actions.clear();
1636 assert_eq!(proposal.rollback_contract(), Reversibility::Reversible);
1637 }
1638}