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.
- agent_consistency/__init__.py +40 -0
- agent_consistency/_time.py +5 -0
- agent_consistency/adapters/__init__.py +15 -0
- agent_consistency/adapters/azure_durable.py +103 -0
- agent_consistency/diff.py +164 -0
- agent_consistency/errors.py +36 -0
- agent_consistency/handoff.py +109 -0
- agent_consistency/models.py +226 -0
- agent_consistency/outcome.py +59 -0
- agent_consistency/run.py +305 -0
- agent_consistency/serialization.py +52 -0
- agent_consistency/store.py +81 -0
- agent_consistency-0.1.0.dist-info/METADATA +217 -0
- agent_consistency-0.1.0.dist-info/RECORD +17 -0
- agent_consistency-0.1.0.dist-info/WHEEL +5 -0
- agent_consistency-0.1.0.dist-info/licenses/LICENSE +160 -0
- agent_consistency-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
)
|