agent-consistency 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ from .diff import DiffItem, RunDiff, diff_runs
2
+ from .errors import (
3
+ ConsistencyError,
4
+ DuplicateReceiptError,
5
+ HandoffValidationError,
6
+ OutcomeVerificationError,
7
+ StaleStateError,
8
+ )
9
+ from .handoff import HandoffPacket
10
+ from .models import ConsistencyIssue, ConsistencyReceipt, OutcomeResult, StateDelta, StateSnapshot
11
+ from .outcome import OutcomeVerifier, verify_outcome
12
+ from .run import AgentStep, WorkflowRun
13
+ from .store import InMemoryReceiptStore, JsonlReceiptStore, ReceiptStore, load_receipts
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "AgentStep",
19
+ "ConsistencyError",
20
+ "ConsistencyIssue",
21
+ "ConsistencyReceipt",
22
+ "DiffItem",
23
+ "DuplicateReceiptError",
24
+ "HandoffPacket",
25
+ "HandoffValidationError",
26
+ "InMemoryReceiptStore",
27
+ "JsonlReceiptStore",
28
+ "OutcomeResult",
29
+ "OutcomeVerificationError",
30
+ "OutcomeVerifier",
31
+ "ReceiptStore",
32
+ "RunDiff",
33
+ "StaleStateError",
34
+ "StateDelta",
35
+ "StateSnapshot",
36
+ "WorkflowRun",
37
+ "diff_runs",
38
+ "load_receipts",
39
+ "verify_outcome",
40
+ ]
@@ -0,0 +1,5 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def utc_now_iso() -> str:
5
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
@@ -0,0 +1,15 @@
1
+ from .azure_durable import (
2
+ DurableConsistencyContext,
3
+ durable_instance_id,
4
+ durable_is_replaying,
5
+ replay_safe_log,
6
+ stable_activity_key,
7
+ )
8
+
9
+ __all__ = [
10
+ "DurableConsistencyContext",
11
+ "durable_instance_id",
12
+ "durable_is_replaying",
13
+ "replay_safe_log",
14
+ "stable_activity_key",
15
+ ]
@@ -0,0 +1,103 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any, Optional
3
+
4
+ from ..models import ConsistencyReceipt
5
+ from ..run import WorkflowRun
6
+ from ..serialization import stable_digest, to_jsonable
7
+ from ..store import ReceiptStore
8
+
9
+
10
+ def _get_attr(context: Any, *names: str) -> Any:
11
+ for name in names:
12
+ if hasattr(context, name):
13
+ value = getattr(context, name)
14
+ return value() if callable(value) else value
15
+ if isinstance(context, Mapping) and name in context:
16
+ return context[name]
17
+ return None
18
+
19
+
20
+ def durable_instance_id(context: Any) -> str:
21
+ value = _get_attr(context, "instance_id", "instanceId", "instanceID")
22
+ return str(value or "durable-instance-unknown")
23
+
24
+
25
+ def durable_is_replaying(context: Any) -> bool:
26
+ value = _get_attr(context, "is_replaying", "isReplaying")
27
+ return bool(value)
28
+
29
+
30
+ def replay_safe_log(
31
+ context: Any,
32
+ logger: Any,
33
+ level: str,
34
+ message: str,
35
+ *args: Any,
36
+ **kwargs: Any,
37
+ ) -> None:
38
+ if durable_is_replaying(context):
39
+ return
40
+ log_method = getattr(logger, level)
41
+ log_method(message, *args, **kwargs)
42
+
43
+
44
+ def stable_activity_key(instance_id: str, activity_name: str, intent: Mapping[str, Any]) -> str:
45
+ digest = stable_digest(
46
+ {
47
+ "instance_id": instance_id,
48
+ "activity_name": activity_name,
49
+ "intent": to_jsonable(intent),
50
+ }
51
+ )
52
+ return f"{instance_id}:{activity_name}:{digest[:16]}"
53
+
54
+
55
+ class DurableConsistencyContext:
56
+ def __init__(
57
+ self,
58
+ context: Any,
59
+ *,
60
+ store: Optional[ReceiptStore] = None,
61
+ on_violation: str = "raise",
62
+ ) -> None:
63
+ self.context = context
64
+ self.run = WorkflowRun(
65
+ durable_instance_id(context),
66
+ store=store,
67
+ on_violation=on_violation,
68
+ )
69
+
70
+ @property
71
+ def is_replaying(self) -> bool:
72
+ return durable_is_replaying(self.context)
73
+
74
+ @property
75
+ def instance_id(self) -> str:
76
+ return durable_instance_id(self.context)
77
+
78
+ def step(self, *args: Any, **kwargs: Any) -> Any:
79
+ return self.run.step(*args, **kwargs)
80
+
81
+ def activity_key(self, activity_name: str, intent: Mapping[str, Any]) -> str:
82
+ return stable_activity_key(self.instance_id, activity_name, intent)
83
+
84
+ def set_custom_status(self, receipt: Optional[ConsistencyReceipt] = None) -> None:
85
+ setter = getattr(self.context, "set_custom_status", None) or getattr(
86
+ self.context,
87
+ "setCustomStatus",
88
+ None,
89
+ )
90
+ if setter is None:
91
+ return
92
+ if receipt is None:
93
+ receipts = self.run.receipts()
94
+ payload = {
95
+ "consistency": {
96
+ "run_id": self.run.run_id,
97
+ "receipt_count": len(receipts),
98
+ "last_status": receipts[-1].status if receipts else "empty",
99
+ }
100
+ }
101
+ else:
102
+ payload = {"consistency": receipt.to_dict()}
103
+ setter(payload)
@@ -0,0 +1,164 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Dict, List
4
+
5
+ from .models import ConsistencyReceipt
6
+ from .serialization import to_jsonable
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DiffItem:
11
+ kind: str
12
+ message: str
13
+ step_id: str = ""
14
+ left: Any = None
15
+ right: Any = None
16
+
17
+ def to_dict(self) -> Dict[str, Any]:
18
+ return {
19
+ "kind": self.kind,
20
+ "step_id": self.step_id,
21
+ "message": self.message,
22
+ "left": to_jsonable(self.left),
23
+ "right": to_jsonable(self.right),
24
+ }
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RunDiff:
29
+ differences: List[DiffItem] = field(default_factory=list)
30
+
31
+ @property
32
+ def is_empty(self) -> bool:
33
+ return not self.differences
34
+
35
+ def summary(self) -> str:
36
+ if self.is_empty:
37
+ return "No consistency differences found."
38
+ return "\n".join(
39
+ f"- [{item.kind}] {item.step_id}: {item.message}" for item in self.differences
40
+ )
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ return {"differences": [item.to_dict() for item in self.differences]}
44
+
45
+
46
+ def diff_runs(left: Iterable[ConsistencyReceipt], right: Iterable[ConsistencyReceipt]) -> RunDiff:
47
+ left_by_step = {receipt.step_id: receipt for receipt in left}
48
+ right_by_step = {receipt.step_id: receipt for receipt in right}
49
+ differences: List[DiffItem] = []
50
+
51
+ for missing in sorted(set(left_by_step) - set(right_by_step)):
52
+ differences.append(
53
+ DiffItem("step_missing", f"step '{missing}' exists only in left run", step_id=missing)
54
+ )
55
+ for missing in sorted(set(right_by_step) - set(left_by_step)):
56
+ differences.append(
57
+ DiffItem("step_added", f"step '{missing}' exists only in right run", step_id=missing)
58
+ )
59
+
60
+ for step_id in sorted(set(left_by_step) & set(right_by_step)):
61
+ _compare_receipts(step_id, left_by_step[step_id], right_by_step[step_id], differences)
62
+
63
+ return RunDiff(differences=differences)
64
+
65
+
66
+ def _compare_receipts(
67
+ step_id: str,
68
+ left: ConsistencyReceipt,
69
+ right: ConsistencyReceipt,
70
+ differences: List[DiffItem],
71
+ ) -> None:
72
+ if left.assumptions != right.assumptions:
73
+ differences.append(
74
+ DiffItem(
75
+ "assumptions",
76
+ "assumptions diverged",
77
+ step_id=step_id,
78
+ left=left.assumptions,
79
+ right=right.assumptions,
80
+ )
81
+ )
82
+ _compare_snapshot_list(
83
+ step_id,
84
+ "state_read",
85
+ "state read diverged",
86
+ [snapshot.to_dict() for snapshot in left.state_reads],
87
+ [snapshot.to_dict() for snapshot in right.state_reads],
88
+ differences,
89
+ )
90
+ _compare_snapshot_list(
91
+ step_id,
92
+ "state_delta",
93
+ "state delta diverged",
94
+ [delta.to_dict() for delta in left.state_deltas],
95
+ [delta.to_dict() for delta in right.state_deltas],
96
+ differences,
97
+ )
98
+ _compare_list(
99
+ step_id,
100
+ "handoff",
101
+ "handoff packet diverged",
102
+ [handoff.to_dict() for handoff in left.handoffs],
103
+ [handoff.to_dict() for handoff in right.handoffs],
104
+ differences,
105
+ ignore_fields={"created_at"},
106
+ )
107
+ _compare_list(
108
+ step_id,
109
+ "outcome",
110
+ "outcome diverged",
111
+ [outcome.to_dict() for outcome in left.outcomes],
112
+ [outcome.to_dict() for outcome in right.outcomes],
113
+ differences,
114
+ ignore_fields={"checked_at"},
115
+ )
116
+
117
+
118
+ def _strip_fields(payload: Any, ignore_fields: set) -> Any:
119
+ if isinstance(payload, Mapping):
120
+ return {
121
+ key: _strip_fields(value, ignore_fields)
122
+ for key, value in payload.items()
123
+ if key not in ignore_fields
124
+ }
125
+ if isinstance(payload, list):
126
+ return [_strip_fields(item, ignore_fields) for item in payload]
127
+ return payload
128
+
129
+
130
+ def _compare_snapshot_list(
131
+ step_id: str,
132
+ kind: str,
133
+ message: str,
134
+ left: List[Dict[str, Any]],
135
+ right: List[Dict[str, Any]],
136
+ differences: List[DiffItem],
137
+ ) -> None:
138
+ _compare_list(
139
+ step_id,
140
+ kind,
141
+ message,
142
+ left,
143
+ right,
144
+ differences,
145
+ ignore_fields={"captured_at"},
146
+ )
147
+
148
+
149
+ def _compare_list(
150
+ step_id: str,
151
+ kind: str,
152
+ message: str,
153
+ left: List[Dict[str, Any]],
154
+ right: List[Dict[str, Any]],
155
+ differences: List[DiffItem],
156
+ *,
157
+ ignore_fields: set,
158
+ ) -> None:
159
+ left_clean = _strip_fields(left, ignore_fields)
160
+ right_clean = _strip_fields(right, ignore_fields)
161
+ if left_clean != right_clean:
162
+ differences.append(
163
+ DiffItem(kind, message, step_id=step_id, left=left_clean, right=right_clean)
164
+ )
@@ -0,0 +1,36 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+
4
+ class ConsistencyError(Exception):
5
+ """Base error for agent workflow consistency violations."""
6
+
7
+
8
+ class StaleStateError(ConsistencyError):
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ snapshot: Optional[Any] = None,
14
+ current: Optional[Any] = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.snapshot = snapshot
18
+ self.current = current
19
+
20
+
21
+ class HandoffValidationError(ConsistencyError):
22
+ def __init__(self, message: str, *, details: Optional[Dict[str, Any]] = None) -> None:
23
+ super().__init__(message)
24
+ self.details = details or {}
25
+
26
+
27
+ class OutcomeVerificationError(ConsistencyError):
28
+ def __init__(self, message: str, *, result: Optional[Any] = None) -> None:
29
+ super().__init__(message)
30
+ self.result = result
31
+
32
+
33
+ class DuplicateReceiptError(ConsistencyError):
34
+ def __init__(self, message: str, *, receipt_key: Optional[str] = None) -> None:
35
+ super().__init__(message)
36
+ self.receipt_key = receipt_key
@@ -0,0 +1,109 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ._time import utc_now_iso
6
+ from .serialization import to_jsonable
7
+
8
+
9
+ def _get_path(payload: Mapping[str, Any], path: str) -> Any:
10
+ if path in payload:
11
+ return payload[path]
12
+ current: Any = payload
13
+ for part in path.split("."):
14
+ if not isinstance(current, Mapping) or part not in current:
15
+ return None
16
+ current = current[part]
17
+ return current
18
+
19
+
20
+ def _is_blank(value: Any) -> bool:
21
+ return value is None or value == "" or value == [] or value == {}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class HandoffPacket:
26
+ from_agent: str
27
+ to_agent: str
28
+ task: str
29
+ facts: Dict[str, Any] = field(default_factory=dict)
30
+ assumptions: List[str] = field(default_factory=list)
31
+ missing_info: List[str] = field(default_factory=list)
32
+ constraints: List[str] = field(default_factory=list)
33
+ evidence: Dict[str, Any] = field(default_factory=dict)
34
+ metadata: Dict[str, Any] = field(default_factory=dict)
35
+ created_at: str = field(default_factory=utc_now_iso)
36
+
37
+ def validate(
38
+ self,
39
+ *,
40
+ required_facts: Optional[Iterable[str]] = None,
41
+ required_assumptions: Optional[Iterable[str]] = None,
42
+ required_constraints: Optional[Iterable[str]] = None,
43
+ required_evidence: Optional[Iterable[str]] = None,
44
+ ) -> List[str]:
45
+ problems: List[str] = []
46
+ for fact in required_facts or []:
47
+ if _is_blank(_get_path(self.facts, fact)):
48
+ detail = "declared missing" if fact in self.missing_info else "missing"
49
+ problems.append(f"required fact '{fact}' is {detail}")
50
+ for assumption in required_assumptions or []:
51
+ if assumption not in self.assumptions:
52
+ problems.append(f"required assumption '{assumption}' is missing")
53
+ for constraint in required_constraints or []:
54
+ if constraint not in self.constraints:
55
+ problems.append(f"required constraint '{constraint}' is missing")
56
+ for evidence_key in required_evidence or []:
57
+ if _is_blank(_get_path(self.evidence, evidence_key)):
58
+ problems.append(f"required evidence '{evidence_key}' is missing")
59
+ return problems
60
+
61
+ def require_supported_claims(
62
+ self,
63
+ claims: Mapping[str, Any],
64
+ *,
65
+ by: Iterable[str],
66
+ ) -> List[str]:
67
+ support_keys = list(by)
68
+ missing_support = [
69
+ key
70
+ for key in support_keys
71
+ if _is_blank(_get_path(self.facts, key)) and _is_blank(_get_path(self.evidence, key))
72
+ ]
73
+ if not claims:
74
+ return ["claims cannot be empty when support is required"]
75
+ if missing_support:
76
+ return [
77
+ "unsupported claims "
78
+ f"{sorted(str(key) for key in claims)}; missing support keys {missing_support}"
79
+ ]
80
+ return []
81
+
82
+ def to_dict(self) -> Dict[str, Any]:
83
+ return {
84
+ "from_agent": self.from_agent,
85
+ "to_agent": self.to_agent,
86
+ "task": self.task,
87
+ "facts": to_jsonable(self.facts),
88
+ "assumptions": list(self.assumptions),
89
+ "missing_info": list(self.missing_info),
90
+ "constraints": list(self.constraints),
91
+ "evidence": to_jsonable(self.evidence),
92
+ "metadata": to_jsonable(self.metadata),
93
+ "created_at": self.created_at,
94
+ }
95
+
96
+ @classmethod
97
+ def from_dict(cls, payload: Mapping[str, Any]) -> "HandoffPacket":
98
+ return cls(
99
+ from_agent=str(payload["from_agent"]),
100
+ to_agent=str(payload["to_agent"]),
101
+ task=str(payload["task"]),
102
+ facts=dict(payload.get("facts") or {}),
103
+ assumptions=list(payload.get("assumptions") or []),
104
+ missing_info=list(payload.get("missing_info") or []),
105
+ constraints=list(payload.get("constraints") or []),
106
+ evidence=dict(payload.get("evidence") or {}),
107
+ metadata=dict(payload.get("metadata") or {}),
108
+ created_at=str(payload.get("created_at") or utc_now_iso()),
109
+ )