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)]
57pub struct ToolFailure {
58 pub message: String,
59 #[serde(default)]
60 pub classification: ToolFailureClassification,
61 #[serde(skip)]
62 unhandled: bool,
63}
64
65const LEGACY_UNHANDLED_PREFIX: &str = "unknown tool";
68
69impl ToolFailure {
70 pub fn ordinary(message: impl Into<String>) -> Self {
74 Self {
75 message: message.into(),
76 classification: ToolFailureClassification::Ordinary,
77 unhandled: false,
78 }
79 }
80
81 pub fn terminal(message: impl Into<String>) -> Self {
82 Self {
83 message: message.into(),
84 classification: ToolFailureClassification::Terminal,
85 unhandled: false,
86 }
87 }
88
89 pub fn unhandled(message: impl Into<String>) -> Self {
94 Self {
95 message: message.into(),
96 classification: ToolFailureClassification::Ordinary,
97 unhandled: true,
98 }
99 }
100
101 pub const fn is_terminal(&self) -> bool {
106 match self.classification {
107 ToolFailureClassification::Ordinary => false,
108 ToolFailureClassification::Terminal => true,
109 }
110 }
111
112 pub const fn is_unhandled(&self) -> bool {
115 self.unhandled && !self.is_terminal()
116 }
117}
118
119impl From<String> for ToolFailure {
126 fn from(message: String) -> Self {
127 if message.starts_with(LEGACY_UNHANDLED_PREFIX) {
128 Self::unhandled(message)
129 } else {
130 Self::ordinary(message)
131 }
132 }
133}
134
135impl From<&str> for ToolFailure {
136 fn from(message: &str) -> Self {
137 Self::from(message.to_string())
138 }
139}
140
141impl std::fmt::Display for ToolFailure {
142 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 formatter.write_str(&self.message)
144 }
145}
146
147impl std::error::Error for ToolFailure {}
148
149#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
151#[serde(rename_all = "snake_case")]
152pub enum ActionStatus {
153 Proposed,
154 Validated,
155 Rejected,
156 Executing,
157 Succeeded,
158 Failed,
159 Skipped,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct Precondition {
167 pub key: String,
168 #[serde(default = "default_operator")]
170 pub operator: String,
171 #[serde(default)]
172 pub value: Value,
173 #[serde(default)]
174 pub description: String,
175}
176
177fn default_operator() -> String {
178 "eq".to_string()
179}
180
181fn short_id() -> String {
183 Uuid::new_v4().simple().to_string()[..12].to_string()
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub struct Action {
209 #[serde(default = "short_id")]
210 pub id: String,
211
212 #[serde(rename = "type")]
213 pub action_type: ActionType,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub tool: Option<String>,
217
218 #[serde(default)]
219 pub parameters: HashMap<String, Value>,
220
221 #[serde(default)]
222 pub preconditions: Vec<Precondition>,
223
224 #[serde(default)]
225 pub expected_effects: HashMap<String, Value>,
226
227 #[serde(default)]
228 pub state_dependencies: Vec<String>,
229
230 #[serde(default)]
238 pub read_set: Vec<String>,
239
240 #[serde(default)]
244 pub write_set: Vec<String>,
245
246 #[serde(default)]
251 pub assumptions: Vec<StateAssumption>,
252
253 #[serde(default)]
260 pub invocation_mode: crate::tool_stream::ToolInvocationMode,
261
262 #[serde(default)]
273 pub reversibility: Reversibility,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub compensation: Option<Compensation>,
289
290 #[serde(default)]
291 pub idempotent: bool,
292
293 #[serde(default = "default_max_retries")]
294 pub max_retries: u32,
295
296 #[serde(default)]
297 pub failure_behavior: FailureBehavior,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub timeout_ms: Option<u64>,
301
302 #[serde(default)]
303 pub metadata: HashMap<String, Value>,
304}
305
306fn default_max_retries() -> u32 {
307 3
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub struct StateAssumption {
318 pub key: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub expected_value: Option<Value>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub read_version: Option<u64>,
327}
328
329impl Action {
330 pub fn new(action_type: ActionType) -> Self {
351 Self {
352 id: short_id(),
353 action_type,
354 tool: None,
355 parameters: HashMap::new(),
356 preconditions: Vec::new(),
357 expected_effects: HashMap::new(),
358 state_dependencies: Vec::new(),
359 read_set: Vec::new(),
360 write_set: Vec::new(),
361 assumptions: Vec::new(),
362 invocation_mode: crate::tool_stream::ToolInvocationMode::default(),
363 reversibility: Reversibility::default(),
364 compensation: None,
365 idempotent: false,
366 max_retries: default_max_retries(),
367 failure_behavior: FailureBehavior::default(),
368 timeout_ms: None,
369 metadata: HashMap::new(),
370 }
371 }
372
373 pub fn tool_call(tool: impl Into<String>) -> Self {
375 let mut a = Self::new(ActionType::ToolCall);
376 a.tool = Some(tool.into());
377 a
378 }
379
380 pub fn state_write(key: impl Into<String>, value: Value) -> Self {
384 let key = key.into();
385 let mut a = Self::new(ActionType::StateWrite);
386 a.parameters
387 .insert("key".to_string(), Value::String(key.clone()));
388 a.parameters.insert("value".to_string(), value.clone());
389 a.expected_effects.insert(key, value);
390 a
391 }
392
393 pub fn state_read(key: impl Into<String>) -> Self {
395 let mut a = Self::new(ActionType::StateRead);
396 a.parameters
397 .insert("key".to_string(), Value::String(key.into()));
398 a
399 }
400
401 pub fn with_id(mut self, id: impl Into<String>) -> Self {
404 self.id = id.into();
405 self
406 }
407
408 pub fn with_param(mut self, key: impl Into<String>, value: Value) -> Self {
410 self.parameters.insert(key.into(), value);
411 self
412 }
413
414 pub fn effective_write_set(&self) -> Vec<String> {
423 let mut keys: Vec<String> = self.write_set.clone();
424 let push = |k: String, keys: &mut Vec<String>| {
425 if !keys.contains(&k) {
426 keys.push(k);
427 }
428 };
429 for k in self.expected_effects.keys() {
430 push(k.clone(), &mut keys);
431 }
432 if self.action_type == ActionType::StateWrite {
433 if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
434 push(k.to_string(), &mut keys);
435 }
436 }
437 keys
438 }
439
440 pub fn effective_read_set(&self) -> Vec<String> {
445 let mut keys: Vec<String> = self.read_set.clone();
446 let push = |k: String, keys: &mut Vec<String>| {
447 if !keys.contains(&k) {
448 keys.push(k);
449 }
450 };
451 for k in &self.state_dependencies {
452 push(k.clone(), &mut keys);
453 }
454 for a in &self.assumptions {
455 push(a.key.clone(), &mut keys);
456 }
457 if self.action_type == ActionType::StateRead {
458 if let Some(k) = self.parameters.get("key").and_then(|v| v.as_str()) {
459 push(k.to_string(), &mut keys);
460 }
461 }
462 keys
463 }
464
465 pub fn missing_required_compensation(&self) -> bool {
476 self.reversibility.requires_compensation() && self.compensation.is_none()
477 }
478}
479
480#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
482pub struct ActionProposal {
483 #[serde(default = "short_id")]
484 pub id: String,
485
486 #[serde(default = "default_source")]
487 pub source: String,
488
489 pub actions: Vec<Action>,
490
491 #[serde(default = "Utc::now")]
492 pub timestamp: DateTime<Utc>,
493
494 #[serde(default)]
495 pub context: HashMap<String, Value>,
496}
497
498fn default_source() -> String {
499 "unknown".to_string()
500}
501
502impl ActionProposal {
503 pub fn rollback_contract(&self) -> Reversibility {
516 self.actions
517 .iter()
518 .map(|a| a.reversibility)
519 .max()
520 .unwrap_or(Reversibility::Reversible)
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(tag = "op", rename_all = "snake_case")]
533pub enum StateMutation {
534 Set {
535 #[serde(default)]
538 value: Value,
539 },
540 Delete,
541}
542
543impl StateMutation {
544 pub fn from_new_value(new_value: Option<Value>) -> Self {
546 match new_value {
547 Some(value) => Self::Set { value },
548 None => Self::Delete,
549 }
550 }
551
552 pub fn encode(self) -> Value {
554 serde_json::to_value(self)
555 .expect("StateMutation contains only infallibly serializable JSON values")
556 }
557
558 pub fn decode(value: &Value) -> Result<Self, serde_json::Error> {
560 serde_json::from_value(value.clone())
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
566pub struct ActionResult {
567 pub action_id: String,
568 pub status: ActionStatus,
569
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub output: Option<Value>,
572
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub error: Option<String>,
575
576 #[serde(default, skip_serializing_if = "is_false")]
579 pub terminal: bool,
580
581 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
590 pub rolled_back: bool,
591
592 #[serde(default)]
593 pub state_changes: HashMap<String, Value>,
594
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub duration_ms: Option<f64>,
597
598 #[serde(default = "Utc::now")]
599 pub timestamp: DateTime<Utc>,
600}
601
602fn is_false(value: &bool) -> bool {
603 !value
604}
605
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
608pub struct ToolRateLimit {
609 pub max_calls: u32,
610 pub interval_secs: f64,
611}
612
613#[derive(
619 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
620)]
621#[serde(rename_all = "snake_case")]
622pub enum ToolSourceKind {
623 Builtin,
624 #[default]
625 UserDefined,
626 Subprocess,
627 Mcp,
628}
629
630impl ToolSourceKind {
631 pub const fn as_str(self) -> &'static str {
632 match self {
633 Self::Builtin => "builtin",
634 Self::UserDefined => "user_defined",
635 Self::Subprocess => "subprocess",
636 Self::Mcp => "mcp",
637 }
638 }
639}
640
641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
646pub struct ToolSchema {
647 pub name: String,
648 #[serde(default)]
651 pub source: ToolSourceKind,
652 #[serde(default)]
653 pub description: String,
654 #[serde(default = "default_parameters_schema")]
656 pub parameters: Value,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub returns: Option<Value>,
660 #[serde(default)]
662 pub idempotent: bool,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub cache_ttl_secs: Option<u64>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub rate_limit: Option<ToolRateLimit>,
669}
670
671fn default_parameters_schema() -> Value {
672 Value::Object(Default::default())
673}
674
675#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
677pub struct CostSummary {
678 pub tool_calls: u32,
679 pub actions_executed: u32,
686 #[serde(default)]
693 pub actions_rejected: u32,
694 pub actions_skipped: u32,
695 pub total_duration_ms: f64,
696 pub retries: u32,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct CostTarget {
708 pub target_tool_calls: u32,
710 pub target_duration_ms: f64,
712 pub target_actions: u32,
714 pub cost_weight: f64,
716}
717
718impl Default for CostTarget {
719 fn default() -> Self {
720 Self {
721 target_tool_calls: 5,
722 target_duration_ms: 5000.0,
723 target_actions: 10,
724 cost_weight: 0.2,
725 }
726 }
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum ProposalLineageStatus {
733 Accepted,
734 Rejected,
735}
736
737#[derive(Debug, Clone, PartialEq, Eq)]
741pub struct ProposalLineageEntry {
742 pub generation: u32,
743 pub proposal_id: String,
744 pub proposal_digest: Option<String>,
745 pub status: ProposalLineageStatus,
746 pub rejection_reason: Option<String>,
747}
748
749impl ProposalLineageEntry {
750 fn validate(&self) -> Result<(), String> {
751 let digest_is_lowercase_sha256 = |digest: &str| {
752 digest.len() == 64
753 && digest
754 .bytes()
755 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
756 };
757
758 match self.status {
759 ProposalLineageStatus::Accepted => {
760 let digest = self.proposal_digest.as_deref().ok_or_else(|| {
761 "accepted proposal lineage requires a lowercase JCS SHA-256 digest".to_string()
762 })?;
763 if !digest_is_lowercase_sha256(digest) {
764 return Err(
765 "accepted proposal lineage requires a lowercase JCS SHA-256 digest"
766 .to_string(),
767 );
768 }
769 if self.rejection_reason.is_some() {
770 return Err(
771 "accepted proposal lineage cannot include a rejection reason".to_string(),
772 );
773 }
774 }
775 ProposalLineageStatus::Rejected => {
776 if let Some(digest) = self.proposal_digest.as_deref() {
777 if !digest_is_lowercase_sha256(digest) {
778 return Err(
779 "rejected proposal lineage digest must be lowercase JCS SHA-256"
780 .to_string(),
781 );
782 }
783 }
784 let reason = self
785 .rejection_reason
786 .as_deref()
787 .filter(|reason| !reason.trim().is_empty())
788 .ok_or_else(|| {
789 "rejected proposal lineage requires an exact rejection reason".to_string()
790 })?;
791 if self.proposal_digest.is_none()
792 && !reason.starts_with("RFC 8785 canonicalization failed")
793 && !reason.starts_with("proposal serialization failed")
794 {
795 return Err(
796 "undigested rejected proposal lineage must identify a JCS/I-JSON failure"
797 .to_string(),
798 );
799 }
800 }
801 }
802 Ok(())
803 }
804}
805
806impl Serialize for ProposalLineageEntry {
807 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
808 where
809 S: serde::Serializer,
810 {
811 use serde::ser::{Error as _, SerializeStruct};
812 self.validate().map_err(S::Error::custom)?;
813 let mut state = serializer.serialize_struct("ProposalLineageEntry", 5)?;
814 state.serialize_field("generation", &self.generation)?;
815 state.serialize_field("proposal_id", &self.proposal_id)?;
816 if let Some(digest) = &self.proposal_digest {
817 state.serialize_field("proposal_digest", digest)?;
818 }
819 state.serialize_field("status", &self.status)?;
820 if let Some(reason) = &self.rejection_reason {
821 state.serialize_field("rejection_reason", reason)?;
822 }
823 state.end()
824 }
825}
826
827impl<'de> Deserialize<'de> for ProposalLineageEntry {
828 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
829 where
830 D: serde::Deserializer<'de>,
831 {
832 #[derive(Deserialize)]
833 struct Wire {
834 generation: u32,
835 proposal_id: String,
836 #[serde(default)]
837 proposal_digest: Option<String>,
838 status: ProposalLineageStatus,
839 #[serde(default)]
840 rejection_reason: Option<String>,
841 }
842
843 let wire = Wire::deserialize(deserializer)?;
844 let entry = Self {
845 generation: wire.generation,
846 proposal_id: wire.proposal_id,
847 proposal_digest: wire.proposal_digest,
848 status: wire.status,
849 rejection_reason: wire.rejection_reason,
850 };
851 entry.validate().map_err(serde::de::Error::custom)?;
852 Ok(entry)
853 }
854}
855
856#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
861pub struct AcceptedProposalPreimage {
862 pub generation: u32,
863 pub proposal_digest: String,
864 pub proposal: ActionProposal,
865}
866
867#[derive(Debug, Clone, PartialEq, Serialize)]
869pub struct ProposalResult {
870 pub proposal_id: String,
873
874 pub original_proposal_id: String,
876
877 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub final_proposal: Option<ActionProposal>,
884
885 #[serde(default, skip_serializing_if = "Vec::is_empty")]
887 pub replan_lineage: Vec<ProposalLineageEntry>,
888
889 #[serde(default, skip_serializing_if = "Vec::is_empty")]
893 pub accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
894
895 #[serde(default)]
896 pub results: Vec<ActionResult>,
897
898 #[serde(default)]
899 pub cost: CostSummary,
900}
901
902impl<'de> Deserialize<'de> for ProposalResult {
903 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
904 where
905 D: serde::Deserializer<'de>,
906 {
907 #[derive(Deserialize)]
908 struct Wire {
909 proposal_id: String,
910 #[serde(default)]
911 original_proposal_id: Option<String>,
912 #[serde(default)]
913 final_proposal: Option<ActionProposal>,
914 #[serde(default)]
915 replan_lineage: Vec<ProposalLineageEntry>,
916 #[serde(default)]
917 accepted_proposal_preimages: Vec<AcceptedProposalPreimage>,
918 #[serde(default)]
919 results: Vec<ActionResult>,
920 #[serde(default)]
921 cost: CostSummary,
922 }
923
924 let wire = Wire::deserialize(deserializer)?;
925 let original_proposal_id = wire
926 .original_proposal_id
927 .unwrap_or_else(|| wire.proposal_id.clone());
928 Ok(Self {
929 proposal_id: wire.proposal_id,
930 original_proposal_id,
931 final_proposal: wire.final_proposal,
932 replan_lineage: wire.replan_lineage,
933 accepted_proposal_preimages: wire.accepted_proposal_preimages,
934 results: wire.results,
935 cost: wire.cost,
936 })
937 }
938}
939
940impl ProposalResult {
941 pub fn new(
942 proposal_id: impl Into<String>,
943 results: Vec<ActionResult>,
944 cost: CostSummary,
945 ) -> Self {
946 let proposal_id = proposal_id.into();
947 Self {
948 original_proposal_id: proposal_id.clone(),
949 proposal_id,
950 final_proposal: None,
951 replan_lineage: Vec::new(),
952 accepted_proposal_preimages: Vec::new(),
953 results,
954 cost,
955 }
956 }
957
958 pub fn for_proposal(
962 proposal: &ActionProposal,
963 results: Vec<ActionResult>,
964 cost: CostSummary,
965 ) -> Self {
966 Self {
967 proposal_id: proposal.id.clone(),
968 original_proposal_id: proposal.id.clone(),
969 final_proposal: Some(proposal.clone()),
970 replan_lineage: Vec::new(),
971 accepted_proposal_preimages: Vec::new(),
972 results,
973 cost,
974 }
975 }
976
977 pub fn all_succeeded(&self) -> bool {
978 self.results
979 .iter()
980 .all(|r| r.status == ActionStatus::Succeeded)
981 }
982
983 pub fn summary(&self) -> HashMap<ActionStatus, usize> {
984 let mut counts = HashMap::new();
985 for r in &self.results {
986 *counts.entry(r.status.clone()).or_insert(0) += 1;
987 }
988 counts
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995 use pretty_assertions::assert_eq;
996
997 #[test]
1000 fn unhandled_is_typed_and_never_crosses_a_wire() {
1001 let forged = "unknown tool: read_file";
1002 assert!(!ToolFailure::ordinary(forged).is_unhandled());
1003 assert!(!ToolFailure::terminal(forged).is_unhandled());
1004 assert!(ToolFailure::unhandled("x").is_unhandled());
1005 assert!(ToolFailure::from(forged.to_string()).is_unhandled());
1007 assert!(ToolFailure::from(forged).is_unhandled());
1008 assert!(!ToolFailure::from("boom".to_string()).is_unhandled());
1009
1010 let wire = serde_json::to_value(ToolFailure::unhandled(forged)).unwrap();
1011 assert_eq!(
1012 wire,
1013 serde_json::json!({"message": forged, "classification": "ordinary"})
1014 );
1015 let read: ToolFailure = serde_json::from_value(serde_json::json!({
1016 "message": forged, "classification": "ordinary", "unhandled": true
1017 }))
1018 .unwrap();
1019 assert!(!read.is_unhandled());
1020 }
1021
1022 #[test]
1023 fn constructors_fill_defaults_and_leave_the_wire_unchanged() {
1024 let a = Action::new(ActionType::ToolCall);
1030 assert_eq!(a.action_type, ActionType::ToolCall);
1031 assert!(a.tool.is_none());
1032 assert!(a.parameters.is_empty());
1033 assert!(a.preconditions.is_empty());
1034 assert!(a.expected_effects.is_empty());
1035 assert!(a.state_dependencies.is_empty());
1036 assert!(a.read_set.is_empty());
1037 assert!(a.write_set.is_empty());
1038 assert!(a.assumptions.is_empty());
1039 assert_eq!(a.reversibility, Reversibility::default());
1040 assert!(a.compensation.is_none());
1041 assert!(!a.idempotent);
1042 assert_eq!(a.max_retries, default_max_retries());
1043 assert_eq!(a.failure_behavior, FailureBehavior::Abort);
1044 assert!(a.timeout_ms.is_none());
1045 assert!(a.metadata.is_empty());
1046 assert_eq!(a.id.len(), 12, "id is a generated short id");
1047
1048 assert_eq!(Action::tool_call("deploy").tool.as_deref(), Some("deploy"));
1050
1051 let sw = Action::state_write("k", Value::from(1));
1052 assert_eq!(sw.action_type, ActionType::StateWrite);
1053 assert_eq!(sw.parameters["key"], Value::from("k"));
1054 assert_eq!(sw.parameters["value"], Value::from(1));
1055 assert_eq!(sw.expected_effects["k"], Value::from(1));
1059 assert_eq!(sw.effective_write_set(), vec!["k".to_string()]);
1060
1061 let sr = Action::state_read("k");
1062 assert_eq!(sr.action_type, ActionType::StateRead);
1063 assert_eq!(sr.parameters["key"], Value::from("k"));
1064
1065 let c = Action::tool_call("t")
1067 .with_id("fixed")
1068 .with_param("p", Value::from(2));
1069 assert_eq!(c.id, "fixed");
1070 assert_eq!(c.parameters["p"], Value::from(2));
1071 }
1072
1073 #[test]
1074 fn action_type_serializes_snake_case() {
1075 assert_eq!(
1076 serde_json::to_string(&ActionType::ToolCall).unwrap(),
1077 "\"tool_call\""
1078 );
1079 assert_eq!(
1080 serde_json::to_string(&ActionType::StateWrite).unwrap(),
1081 "\"state_write\""
1082 );
1083 }
1084
1085 #[test]
1086 fn failure_behavior_serializes_snake_case() {
1087 assert_eq!(
1088 serde_json::to_string(&FailureBehavior::Abort).unwrap(),
1089 "\"abort\""
1090 );
1091 assert_eq!(
1092 serde_json::to_string(&FailureBehavior::Retry).unwrap(),
1093 "\"retry\""
1094 );
1095 }
1096
1097 #[test]
1098 fn action_roundtrip_json() {
1099 let action = Action {
1100 id: "abc123".to_string(),
1101 action_type: ActionType::ToolCall,
1102 tool: Some("add".to_string()),
1103 parameters: [
1104 ("a".to_string(), Value::from(1)),
1105 ("b".to_string(), Value::from(2)),
1106 ]
1107 .into(),
1108 preconditions: vec![Precondition {
1109 key: "auth".to_string(),
1110 operator: "eq".to_string(),
1111 value: Value::Bool(true),
1112 description: String::new(),
1113 }],
1114 expected_effects: [("sum".to_string(), Value::from(3))].into(),
1115 state_dependencies: vec!["auth".to_string()],
1116 read_set: vec![],
1117 write_set: vec![],
1118 assumptions: vec![],
1119 invocation_mode: Default::default(),
1120 reversibility: Reversibility::Compensable,
1121 compensation: Some(Compensation::Tool {
1122 tool: "subtract".to_string(),
1123 parameters: [("sum".to_string(), Value::from(3))].into(),
1124 }),
1125 idempotent: true,
1126 max_retries: 3,
1127 failure_behavior: FailureBehavior::Retry,
1128 timeout_ms: Some(5000),
1129 metadata: HashMap::new(),
1130 };
1131
1132 let json = serde_json::to_string_pretty(&action).unwrap();
1133 let roundtripped: Action = serde_json::from_str(&json).unwrap();
1134
1135 assert_eq!(action.id, roundtripped.id);
1136 assert_eq!(action.action_type, roundtripped.action_type);
1137 assert_eq!(action.tool, roundtripped.tool);
1138 assert_eq!(action.idempotent, roundtripped.idempotent);
1139 assert_eq!(action.failure_behavior, roundtripped.failure_behavior);
1140 assert_eq!(action.timeout_ms, roundtripped.timeout_ms);
1141 assert_eq!(action.reversibility, roundtripped.reversibility);
1142 assert_eq!(action.compensation, roundtripped.compensation);
1143 assert_eq!(action, roundtripped);
1144 }
1145
1146 #[test]
1147 fn proposal_roundtrip_json() {
1148 let proposal = ActionProposal {
1149 id: "prop1".to_string(),
1150 source: "test".to_string(),
1151 actions: vec![Action {
1152 id: "a1".to_string(),
1153 action_type: ActionType::StateWrite,
1154 tool: None,
1155 parameters: [
1156 ("key".to_string(), Value::from("x")),
1157 ("value".to_string(), Value::from(42)),
1158 ]
1159 .into(),
1160 preconditions: vec![],
1161 expected_effects: HashMap::new(),
1162 state_dependencies: vec![],
1163 read_set: vec![],
1164 write_set: vec![],
1165 assumptions: vec![],
1166 invocation_mode: Default::default(),
1167 reversibility: Reversibility::Reversible,
1168 compensation: None,
1169 idempotent: false,
1170 max_retries: 3,
1171 failure_behavior: FailureBehavior::Abort,
1172 timeout_ms: None,
1173 metadata: HashMap::new(),
1174 }],
1175 timestamp: Utc::now(),
1176 context: HashMap::new(),
1177 };
1178
1179 let json = serde_json::to_string(&proposal).unwrap();
1180 let roundtripped: ActionProposal = serde_json::from_str(&json).unwrap();
1181
1182 assert_eq!(proposal.id, roundtripped.id);
1183 assert_eq!(proposal.source, roundtripped.source);
1184 assert_eq!(proposal.actions.len(), roundtripped.actions.len());
1185 }
1186
1187 #[test]
1188 fn state_mutation_round_trips_the_stable_wire_shape() {
1189 let set = StateMutation::Set {
1190 value: Value::from(42),
1191 };
1192 let set_wire = set.clone().encode();
1193 assert_eq!(set_wire, serde_json::json!({"op": "set", "value": 42}));
1194 assert_eq!(StateMutation::decode(&set_wire).unwrap(), set);
1195
1196 let delete = StateMutation::Delete;
1197 let delete_wire = delete.clone().encode();
1198 assert_eq!(delete_wire, serde_json::json!({"op": "delete"}));
1199 assert_eq!(StateMutation::decode(&delete_wire).unwrap(), delete);
1200
1201 assert_eq!(
1202 StateMutation::decode(&serde_json::json!({"op": "set"})).unwrap(),
1203 StateMutation::Set { value: Value::Null },
1204 "a missing set value retains the previous set-to-null interpretation"
1205 );
1206 assert!(StateMutation::decode(&serde_json::json!({"legacy": true})).is_err());
1207 }
1208
1209 #[test]
1210 fn action_result_serializes() {
1211 let result = ActionResult {
1212 action_id: "a1".to_string(),
1213 status: ActionStatus::Succeeded,
1214 output: Some(Value::from(42)),
1215 error: None,
1216 terminal: false,
1217 state_changes: HashMap::new(),
1218 rolled_back: false,
1219 duration_ms: Some(1.5),
1220 timestamp: Utc::now(),
1221 };
1222
1223 let json = serde_json::to_string(&result).unwrap();
1224 assert!(json.contains("\"succeeded\""));
1225 assert!(!json.contains("\"terminal\""));
1226 assert!(
1227 !json.contains("rolled_back"),
1228 "the additive false marker stays absent on the wire"
1229 );
1230
1231 let mut rolled_back = result;
1232 rolled_back.rolled_back = true;
1233 let value = serde_json::to_value(&rolled_back).unwrap();
1234 assert_eq!(value["rolled_back"], true);
1235
1236 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1237 "action_id": "legacy",
1238 "status": "succeeded",
1239 "state_changes": {}
1240 }))
1241 .unwrap();
1242 assert!(!legacy.rolled_back);
1243 }
1244
1245 #[test]
1246 fn terminal_tool_failure_is_explicit_and_roundtrips_on_action_results() {
1247 let ordinary = ToolFailure::from("ordinary failure");
1248 assert_eq!(ordinary.classification, ToolFailureClassification::Ordinary);
1249 assert!(!ordinary.is_terminal());
1250
1251 let failure = ToolFailure::terminal("stop now");
1252 assert_eq!(failure.classification, ToolFailureClassification::Terminal);
1253 assert!(failure.is_terminal());
1254 assert_eq!(
1255 serde_json::to_value(&failure).unwrap(),
1256 serde_json::json!({
1257 "message": "stop now",
1258 "classification": "terminal"
1259 })
1260 );
1261
1262 let result: ActionResult = serde_json::from_value(serde_json::json!({
1263 "action_id": "a1",
1264 "status": "failed",
1265 "error": "stop now",
1266 "terminal": true
1267 }))
1268 .unwrap();
1269 assert!(result.terminal);
1270 assert!(!result.rolled_back);
1271
1272 let legacy: ActionResult = serde_json::from_value(serde_json::json!({
1273 "action_id": "a2",
1274 "status": "failed",
1275 "error": "ordinary failure"
1276 }))
1277 .unwrap();
1278 assert!(!legacy.terminal);
1279 assert!(!legacy.rolled_back);
1280 }
1281
1282 #[test]
1283 fn proposal_result_all_succeeded() {
1284 let pr = ProposalResult {
1285 proposal_id: "p1".to_string(),
1286 original_proposal_id: "p1".to_string(),
1287 final_proposal: None,
1288 replan_lineage: vec![],
1289 accepted_proposal_preimages: vec![],
1290 results: vec![
1291 ActionResult {
1292 action_id: "a1".to_string(),
1293 status: ActionStatus::Succeeded,
1294 output: None,
1295 error: None,
1296 terminal: false,
1297 state_changes: HashMap::new(),
1298 rolled_back: false,
1299 duration_ms: None,
1300 timestamp: Utc::now(),
1301 },
1302 ActionResult {
1303 action_id: "a2".to_string(),
1304 status: ActionStatus::Succeeded,
1305 output: None,
1306 error: None,
1307 terminal: false,
1308 state_changes: HashMap::new(),
1309 rolled_back: false,
1310 duration_ms: None,
1311 timestamp: Utc::now(),
1312 },
1313 ],
1314 cost: CostSummary::default(),
1315 };
1316 assert!(pr.all_succeeded());
1317 }
1318
1319 #[test]
1320 fn proposal_result_not_all_succeeded() {
1321 let pr = ProposalResult {
1322 proposal_id: "p1".to_string(),
1323 original_proposal_id: "p1".to_string(),
1324 final_proposal: None,
1325 replan_lineage: vec![],
1326 accepted_proposal_preimages: vec![],
1327 results: vec![
1328 ActionResult {
1329 action_id: "a1".to_string(),
1330 status: ActionStatus::Succeeded,
1331 output: None,
1332 error: None,
1333 terminal: false,
1334 state_changes: HashMap::new(),
1335 rolled_back: false,
1336 duration_ms: None,
1337 timestamp: Utc::now(),
1338 },
1339 ActionResult {
1340 action_id: "a2".to_string(),
1341 status: ActionStatus::Failed,
1342 output: None,
1343 error: Some("boom".to_string()),
1344 terminal: false,
1345 state_changes: HashMap::new(),
1346 rolled_back: false,
1347 duration_ms: None,
1348 timestamp: Utc::now(),
1349 },
1350 ],
1351 cost: CostSummary::default(),
1352 };
1353 assert!(!pr.all_succeeded());
1354 }
1355
1356 #[test]
1357 fn cost_summary_default_is_zero() {
1358 let cost = CostSummary::default();
1359 assert_eq!(cost.tool_calls, 0);
1360 assert_eq!(cost.actions_executed, 0);
1361 assert_eq!(cost.actions_rejected, 0);
1362 assert_eq!(cost.actions_skipped, 0);
1363 assert_eq!(cost.total_duration_ms, 0.0);
1364 assert_eq!(cost.retries, 0);
1365 }
1366
1367 #[test]
1369 fn cost_summary_deserializes_without_actions_rejected() {
1370 let legacy = r#"{"tool_calls":1,"actions_executed":2,"actions_skipped":0,
1371 "total_duration_ms":5.0,"retries":0}"#;
1372 let cost: CostSummary = serde_json::from_str(legacy).unwrap();
1373 assert_eq!(cost.actions_executed, 2);
1374 assert_eq!(cost.actions_rejected, 0);
1375 }
1376
1377 #[test]
1378 fn proposal_result_deserializes_without_cost() {
1379 let json = r#"{"proposal_id": "p1", "results": []}"#;
1381 let pr: ProposalResult = serde_json::from_str(json).unwrap();
1382 assert_eq!(pr.cost, CostSummary::default());
1383 assert_eq!(pr.original_proposal_id, "p1");
1384 assert_eq!(pr.final_proposal, None);
1385 assert!(pr.replan_lineage.is_empty());
1386 assert!(pr.accepted_proposal_preimages.is_empty());
1387 }
1388
1389 #[test]
1390 fn proposal_result_lineage_has_exact_tagged_wire_shape() {
1391 let original_digest = "a".repeat(64);
1392 let candidate_digest = "b".repeat(64);
1393 let accepted_digest = "c".repeat(64);
1394 let final_proposal = ActionProposal {
1395 id: "accepted-replan".to_string(),
1396 source: "replanner".to_string(),
1397 actions: vec![],
1398 timestamp: Utc::now(),
1399 context: HashMap::new(),
1400 };
1401 let pr = ProposalResult {
1402 proposal_id: "accepted-replan".to_string(),
1403 original_proposal_id: "original".to_string(),
1404 final_proposal: Some(final_proposal.clone()),
1405 replan_lineage: vec![
1406 ProposalLineageEntry {
1407 generation: 0,
1408 proposal_id: "original".to_string(),
1409 proposal_digest: Some(original_digest.clone()),
1410 status: ProposalLineageStatus::Accepted,
1411 rejection_reason: None,
1412 },
1413 ProposalLineageEntry {
1414 generation: 1,
1415 proposal_id: "duplicate-candidate".to_string(),
1416 proposal_digest: Some(candidate_digest.clone()),
1417 status: ProposalLineageStatus::Rejected,
1418 rejection_reason: Some("duplicate action id 'same'".to_string()),
1419 },
1420 ProposalLineageEntry {
1421 generation: 2,
1422 proposal_id: "accepted-replan".to_string(),
1423 proposal_digest: Some(accepted_digest.clone()),
1424 status: ProposalLineageStatus::Accepted,
1425 rejection_reason: None,
1426 },
1427 ],
1428 accepted_proposal_preimages: vec![AcceptedProposalPreimage {
1429 generation: 2,
1430 proposal_digest: accepted_digest.clone(),
1431 proposal: final_proposal.clone(),
1432 }],
1433 results: vec![],
1434 cost: CostSummary::default(),
1435 };
1436 let value = serde_json::to_value(&pr).unwrap();
1437 assert_eq!(value["proposal_id"], "accepted-replan");
1438 assert_eq!(value["original_proposal_id"], "original");
1439 assert_eq!(value["final_proposal"], serde_json::json!(final_proposal));
1440 assert_eq!(
1441 value["replan_lineage"],
1442 serde_json::json!([
1443 {
1444 "generation": 0,
1445 "proposal_id": "original",
1446 "proposal_digest": original_digest,
1447 "status": "accepted"
1448 },
1449 {
1450 "generation": 1,
1451 "proposal_id": "duplicate-candidate",
1452 "proposal_digest": candidate_digest,
1453 "status": "rejected",
1454 "rejection_reason": "duplicate action id 'same'"
1455 },
1456 {
1457 "generation": 2,
1458 "proposal_id": "accepted-replan",
1459 "proposal_digest": accepted_digest,
1460 "status": "accepted"
1461 }
1462 ])
1463 );
1464 assert_eq!(
1465 value["accepted_proposal_preimages"],
1466 serde_json::json!([{
1467 "generation": 2,
1468 "proposal_digest": accepted_digest,
1469 "proposal": final_proposal,
1470 }])
1471 );
1472 }
1473
1474 #[test]
1475 fn proposal_lineage_rejects_missing_or_noncanonical_accepted_digest() {
1476 for digest in [Value::Null, Value::from("A".repeat(64)), Value::from("abc")] {
1477 let value = serde_json::json!({
1478 "generation": 0,
1479 "proposal_id": "p",
1480 "proposal_digest": digest,
1481 "status": "accepted"
1482 });
1483 let error = serde_json::from_value::<ProposalLineageEntry>(value).unwrap_err();
1484 assert!(
1485 error
1486 .to_string()
1487 .contains("accepted proposal lineage requires a lowercase JCS SHA-256 digest"),
1488 "unexpected error: {error}"
1489 );
1490 }
1491 }
1492
1493 #[test]
1494 fn rejected_lineage_without_digest_requires_exact_reason() {
1495 let valid: ProposalLineageEntry = serde_json::from_value(serde_json::json!({
1496 "generation": 1,
1497 "proposal_id": "not-jcs",
1498 "status": "rejected",
1499 "rejection_reason": "RFC 8785 canonicalization failed: number is outside the I-JSON safe integer range"
1500 }))
1501 .unwrap();
1502 assert!(valid.proposal_digest.is_none());
1503
1504 let error = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1505 "generation": 1,
1506 "proposal_id": "not-jcs",
1507 "status": "rejected"
1508 }))
1509 .unwrap_err();
1510 assert!(error
1511 .to_string()
1512 .contains("rejected proposal lineage requires an exact rejection reason"));
1513
1514 let wrong_reason = serde_json::from_value::<ProposalLineageEntry>(serde_json::json!({
1515 "generation": 1,
1516 "proposal_id": "not-jcs",
1517 "status": "rejected",
1518 "rejection_reason": "duplicate action id"
1519 }))
1520 .unwrap_err();
1521 assert!(wrong_reason
1522 .to_string()
1523 .contains("undigested rejected proposal lineage must identify a JCS/I-JSON failure"));
1524 }
1525
1526 #[test]
1527 fn tool_source_kind_uses_the_stable_wire_vocabulary() {
1528 let cases = [
1529 (ToolSourceKind::Builtin, "builtin"),
1530 (ToolSourceKind::UserDefined, "user_defined"),
1531 (ToolSourceKind::Subprocess, "subprocess"),
1532 (ToolSourceKind::Mcp, "mcp"),
1533 ];
1534 for (source, expected) in cases {
1535 assert_eq!(source.as_str(), expected);
1536 assert_eq!(
1537 serde_json::to_value(source).unwrap(),
1538 serde_json::json!(expected)
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn legacy_tool_schema_defaults_to_user_defined_source() {
1545 let schema: ToolSchema = serde_json::from_value(serde_json::json!({
1546 "name": "legacy",
1547 "parameters": {}
1548 }))
1549 .unwrap();
1550 assert_eq!(schema.source, ToolSourceKind::UserDefined);
1551 assert_eq!(
1552 serde_json::to_value(schema).unwrap()["source"],
1553 "user_defined"
1554 );
1555 }
1556
1557 #[test]
1558 fn deserialize_from_python_compatible_json() {
1559 let json = r#"{
1561 "id": "test123",
1562 "type": "tool_call",
1563 "tool": "add",
1564 "parameters": {"a": 1, "b": 2},
1565 "preconditions": [],
1566 "expected_effects": {"sum": 3},
1567 "state_dependencies": [],
1568 "idempotent": true,
1569 "max_retries": 3,
1570 "failure_behavior": "retry",
1571 "timeout_ms": 5000,
1572 "metadata": {}
1573 }"#;
1574
1575 let action: Action = serde_json::from_str(json).unwrap();
1576 assert_eq!(action.id, "test123");
1577 assert_eq!(action.action_type, ActionType::ToolCall);
1578 assert_eq!(action.tool, Some("add".to_string()));
1579 assert!(action.idempotent);
1580 assert_eq!(action.failure_behavior, FailureBehavior::Retry);
1581 assert_eq!(action.timeout_ms, Some(5000));
1582 }
1583
1584 #[test]
1590 fn action_deserializes_without_reversibility_fields() {
1591 let legacy = r#"{
1592 "id": "legacy1",
1593 "type": "tool_call",
1594 "tool": "send_email",
1595 "parameters": {"to": "a@b.c"},
1596 "preconditions": [],
1597 "expected_effects": {},
1598 "state_dependencies": [],
1599 "idempotent": false,
1600 "max_retries": 3,
1601 "failure_behavior": "abort",
1602 "metadata": {}
1603 }"#;
1604
1605 let action: Action = serde_json::from_str(legacy).unwrap();
1606 assert_eq!(action.id, "legacy1");
1607 assert_eq!(action.reversibility, Reversibility::Irreversible);
1608 assert_eq!(action.compensation, None);
1609 assert!(!action.missing_required_compensation());
1612 }
1613
1614 #[test]
1617 fn proposal_deserializes_without_reversibility_fields() {
1618 let legacy = r#"{
1619 "id": "prop-legacy",
1620 "source": "python",
1621 "actions": [
1622 {"id": "a1", "type": "state_write", "parameters": {"key": "x", "value": 1}},
1623 {"id": "a2", "type": "tool_call", "tool": "add", "parameters": {}}
1624 ],
1625 "context": {}
1626 }"#;
1627
1628 let proposal: ActionProposal = serde_json::from_str(legacy).unwrap();
1629 assert_eq!(proposal.actions.len(), 2);
1630 for action in &proposal.actions {
1631 assert_eq!(action.reversibility, Reversibility::Irreversible);
1632 assert!(action.compensation.is_none());
1633 }
1634 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1635 }
1636
1637 #[test]
1641 fn absent_compensation_is_omitted_from_the_wire_form() {
1642 let mut action: Action =
1643 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"noop"}"#).unwrap();
1644 action.reversibility = Reversibility::Reversible;
1645
1646 let json = serde_json::to_value(&action).unwrap();
1647 assert_eq!(json["reversibility"], "reversible");
1648 assert!(
1649 json.get("compensation").is_none(),
1650 "compensation should be skipped when None, got {json}"
1651 );
1652 }
1653
1654 #[test]
1655 fn compensable_without_compensation_is_flagged() {
1656 let mut action: Action =
1657 serde_json::from_str(r#"{"id":"a1","type":"tool_call","tool":"db.insert"}"#).unwrap();
1658
1659 action.reversibility = Reversibility::Compensable;
1660 assert!(action.missing_required_compensation());
1661
1662 action.compensation = Some(Compensation::ActionRef {
1663 action_id: "undo-a1".to_string(),
1664 });
1665 assert!(!action.missing_required_compensation());
1666
1667 action.reversibility = Reversibility::Reversible;
1670 assert!(!action.missing_required_compensation());
1671 }
1672
1673 #[test]
1674 fn rollback_contract_is_the_worst_action_in_the_batch() {
1675 let mut proposal: ActionProposal = serde_json::from_str(
1676 r#"{"id":"p1","source":"test","actions":[
1677 {"id":"a1","type":"state_write","parameters":{"key":"x"},"reversibility":"reversible"},
1678 {"id":"a2","type":"tool_call","tool":"db.insert","reversibility":"compensable",
1679 "compensation":{"type":"tool","tool":"db.delete","parameters":{"id":1}}}
1680 ]}"#,
1681 )
1682 .unwrap();
1683 assert_eq!(proposal.rollback_contract(), Reversibility::Compensable);
1684
1685 proposal.actions[0].reversibility = Reversibility::Irreversible;
1686 assert_eq!(proposal.rollback_contract(), Reversibility::Irreversible);
1687
1688 proposal.actions.clear();
1691 assert_eq!(proposal.rollback_contract(), Reversibility::Reversible);
1692 }
1693}