agent-postmortem 0.3.4__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_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""The return state of a session subagent (docs/DESIGN.md 4.1, 4.13.1 "Return evidence", G-A).
|
|
2
|
+
|
|
3
|
+
A pure decision over what the parent side recorded: the launcher transcript's launches and their
|
|
4
|
+
results, every inspected transcript's SendMessage requests (resume rounds), and the main
|
|
5
|
+
transcript's accepted task notifications. The reader gathers the facts while it streams a
|
|
6
|
+
transcript (`ReturnFacts`, filled by reader._LaunchLog and by the main transcript's ParentIndex)
|
|
7
|
+
and calls `decide`; nothing here touches the filesystem, and nothing reads a modification time.
|
|
8
|
+
Standard library only.
|
|
9
|
+
|
|
10
|
+
Truth table T0 to T5 (4.13.1). T0: only the measured shapes are recognized, every other presence
|
|
11
|
+
or type is `unknown`/`malformed`. Whatever the table does not assign is `unknown`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ABSENT = object()
|
|
23
|
+
|
|
24
|
+
AGENT_LAUNCH_TOOLS = ("Agent", "Task")
|
|
25
|
+
SEND_TOOL = "SendMessage"
|
|
26
|
+
RECORDED_TOOLS = (*AGENT_LAUNCH_TOOLS, SEND_TOOL)
|
|
27
|
+
_TERMINAL_WORDS = frozenset({"completed", "failed", "killed", "stopped", "cancelled"})
|
|
28
|
+
_STOPPED_WORDS = frozenset({"killed", "stopped", "cancelled"})
|
|
29
|
+
QUEUED_PREFIX = "Message queued for delivery"
|
|
30
|
+
|
|
31
|
+
RETURNED = "returned"
|
|
32
|
+
REPORTED_FAILED = "reported_failed"
|
|
33
|
+
REPORTED_KILLED = "reported_killed"
|
|
34
|
+
PENDING = "pending"
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _id_key(value: object) -> str:
|
|
39
|
+
try:
|
|
40
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=True)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
return repr(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class UseInfo:
|
|
47
|
+
"""One assistant `tool_use` block of a launch or resume tool."""
|
|
48
|
+
|
|
49
|
+
name: str | None
|
|
50
|
+
line: int
|
|
51
|
+
ts: datetime | None
|
|
52
|
+
to: object = ABSENT
|
|
53
|
+
recipient: object = ABSENT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ResultInfo:
|
|
58
|
+
"""One `tool_result` block and the entry's `toolUseResult`, for the tools above."""
|
|
59
|
+
|
|
60
|
+
line: int
|
|
61
|
+
digest: str | None
|
|
62
|
+
is_error: bool
|
|
63
|
+
tur_kind: str # absent | object | string | other
|
|
64
|
+
agent_ids: tuple[str, ...]
|
|
65
|
+
bad_id: bool # an agentId is present and not a string
|
|
66
|
+
status: object = ABSENT
|
|
67
|
+
is_async: object = ABSENT
|
|
68
|
+
resumed: object = ABSENT
|
|
69
|
+
success: object = ABSENT
|
|
70
|
+
message: object = ABSENT
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Note:
|
|
75
|
+
"""An accepted main-transcript task notification (4.13.3)."""
|
|
76
|
+
|
|
77
|
+
line: int
|
|
78
|
+
task_id: str
|
|
79
|
+
tool_use_id: str
|
|
80
|
+
status: str
|
|
81
|
+
ts: datetime | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ReturnFacts:
|
|
86
|
+
"""What one transcript recorded about launches and resume requests."""
|
|
87
|
+
|
|
88
|
+
path: Path | None = None
|
|
89
|
+
unreadable: bool = False
|
|
90
|
+
uses: dict[str, list[UseInfo]] = field(default_factory=dict)
|
|
91
|
+
results: dict[str, list[ResultInfo]] = field(default_factory=dict)
|
|
92
|
+
use_counts: dict[str, int] = field(default_factory=dict)
|
|
93
|
+
# GA-4 (4.13.1 T0): an Agent, Task or SendMessage `tool_use` whose id is not a string cannot
|
|
94
|
+
# key a launch or a request. `bad_named` is the bounded fact kept for it: every string named
|
|
95
|
+
# by its `to` or `recipient`, and every string agent id a result with the same non-string
|
|
96
|
+
# `tool_use_id` carries. `bad_links` keys those ids (as JSON text) to let such a result in.
|
|
97
|
+
bad_named: set[str] = field(default_factory=set)
|
|
98
|
+
bad_links: dict[str, str] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
def note_use(
|
|
101
|
+
self, name: str | None, tool_id: object, line: int, ts: datetime | None, tool_input: object,
|
|
102
|
+
) -> None:
|
|
103
|
+
if not isinstance(tool_id, str):
|
|
104
|
+
if name in RECORDED_TOOLS:
|
|
105
|
+
self.bad_links[_id_key(tool_id)] = name
|
|
106
|
+
if isinstance(tool_input, dict):
|
|
107
|
+
self.bad_named.update(
|
|
108
|
+
v for v in (tool_input.get("to"), tool_input.get("recipient"))
|
|
109
|
+
if isinstance(v, str)
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
self.use_counts[tool_id] = self.use_counts.get(tool_id, 0) + 1
|
|
113
|
+
if name not in RECORDED_TOOLS:
|
|
114
|
+
return
|
|
115
|
+
data = tool_input if isinstance(tool_input, dict) else {}
|
|
116
|
+
self.uses.setdefault(tool_id, []).append(UseInfo(
|
|
117
|
+
name, line, ts,
|
|
118
|
+
data["to"] if "to" in data else ABSENT,
|
|
119
|
+
data["recipient"] if "recipient" in data else ABSENT,
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
def note_bad_result(self, tool_id: object, block: dict, tur: object) -> None:
|
|
123
|
+
"""A `tool_result` whose `tool_use_id` is not a string: when it is the id of a recorded
|
|
124
|
+
malformed use, the children it names are malformed too."""
|
|
125
|
+
if _id_key(tool_id) not in self.bad_links:
|
|
126
|
+
return
|
|
127
|
+
data = tur if isinstance(tur, dict) else {}
|
|
128
|
+
self.bad_named.update(
|
|
129
|
+
v for v in (block.get("agentId"), data.get("agentId"), data.get("resumedAgentId"))
|
|
130
|
+
if isinstance(v, str)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def note_result(
|
|
134
|
+
self, tool_id: str, block: dict, tur: object, tur_present: bool, line: int,
|
|
135
|
+
) -> None:
|
|
136
|
+
if tool_id not in self.uses:
|
|
137
|
+
return
|
|
138
|
+
is_error = block.get("is_error") is True
|
|
139
|
+
ids: list[str] = []
|
|
140
|
+
bad = False
|
|
141
|
+
for value in (block.get("agentId", ABSENT), tur.get("agentId", ABSENT) if isinstance(
|
|
142
|
+
tur, dict) else ABSENT):
|
|
143
|
+
if value is ABSENT:
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
ids.append(value)
|
|
147
|
+
else:
|
|
148
|
+
bad = True
|
|
149
|
+
kind = "absent"
|
|
150
|
+
if tur_present:
|
|
151
|
+
kind = "other"
|
|
152
|
+
if isinstance(tur, dict):
|
|
153
|
+
kind = "object"
|
|
154
|
+
elif isinstance(tur, str):
|
|
155
|
+
kind = "string"
|
|
156
|
+
data = tur if isinstance(tur, dict) else {}
|
|
157
|
+
try:
|
|
158
|
+
digest = hashlib.sha256(
|
|
159
|
+
json.dumps([block, tur if tur_present else None, tur_present], sort_keys=True,
|
|
160
|
+
ensure_ascii=True).encode("ascii")
|
|
161
|
+
).hexdigest()
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
digest = None
|
|
164
|
+
self.results.setdefault(tool_id, []).append(ResultInfo(
|
|
165
|
+
line, digest, is_error, kind, tuple(ids), bad,
|
|
166
|
+
data.get("status", ABSENT), data.get("isAsync", ABSENT),
|
|
167
|
+
data.get("resumedAgentId", ABSENT), data.get("success", ABSENT),
|
|
168
|
+
data.get("message", ABSENT),
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class Decision:
|
|
174
|
+
state: str
|
|
175
|
+
reason: str = "none"
|
|
176
|
+
resumes: int = 0
|
|
177
|
+
# what decided a `reported_failed` state: ("launch_result" | "notification", line, in_launcher)
|
|
178
|
+
evidence: tuple[str, int, bool] | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalized_status(word: object) -> str:
|
|
182
|
+
"""completed | failed | killed (killed, stopped, cancelled) | unrecognized."""
|
|
183
|
+
if word == "completed":
|
|
184
|
+
return "completed"
|
|
185
|
+
if word == "failed":
|
|
186
|
+
return "failed"
|
|
187
|
+
if isinstance(word, str) and word in _STOPPED_WORDS:
|
|
188
|
+
return "killed"
|
|
189
|
+
return "unrecognized"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_STATE_OF_WORD = {"completed": RETURNED, "failed": REPORTED_FAILED, "killed": REPORTED_KILLED}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def launcher_of(meta: dict, sibling_ids, own_id: str) -> tuple[str | None, str]:
|
|
196
|
+
"""T0/T1 over the child's meta: ("main", "none"), (parent subagent id, "none"), or
|
|
197
|
+
(None, reason) with reason `malformed` or `parent_missing`."""
|
|
198
|
+
parent = meta.get("parentAgentId", ABSENT)
|
|
199
|
+
depth = meta.get("spawnDepth", ABSENT)
|
|
200
|
+
if parent is not ABSENT and (
|
|
201
|
+
not isinstance(parent, str) or not parent or parent == own_id
|
|
202
|
+
):
|
|
203
|
+
return None, "malformed"
|
|
204
|
+
if depth is not ABSENT and (
|
|
205
|
+
not isinstance(depth, int) or isinstance(depth, bool) or depth < 1
|
|
206
|
+
):
|
|
207
|
+
return None, "malformed"
|
|
208
|
+
if meta.get("toolUseId", ABSENT) is not ABSENT and not isinstance(meta["toolUseId"], str):
|
|
209
|
+
return None, "malformed"
|
|
210
|
+
if parent is ABSENT:
|
|
211
|
+
return ("main", "none") if depth is ABSENT or depth < 2 else (None, "parent_missing")
|
|
212
|
+
if parent in sibling_ids:
|
|
213
|
+
return parent, "none"
|
|
214
|
+
return None, "parent_missing"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _aware(ts: datetime) -> datetime:
|
|
218
|
+
return ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _distinct(results: list[ResultInfo]) -> list[ResultInfo]:
|
|
222
|
+
seen: set[str] = set()
|
|
223
|
+
out = []
|
|
224
|
+
for result in results:
|
|
225
|
+
if result.digest is not None:
|
|
226
|
+
if result.digest in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(result.digest)
|
|
229
|
+
out.append(result)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _send_uses(facts: ReturnFacts):
|
|
234
|
+
for tool_id, uses in facts.uses.items():
|
|
235
|
+
if uses and uses[0].name == SEND_TOOL:
|
|
236
|
+
yield tool_id, uses
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class _Effect:
|
|
241
|
+
"""What one SendMessage request does to one child."""
|
|
242
|
+
|
|
243
|
+
kind: str # resume | malformed | delivery_unknown
|
|
244
|
+
tool_id: str = ""
|
|
245
|
+
ts: datetime | None = None
|
|
246
|
+
line: int = 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def request_effects(
|
|
250
|
+
facts: ReturnFacts, tool_id: str, uses: list[UseInfo], siblings: frozenset[str] = frozenset(),
|
|
251
|
+
) -> dict[str, _Effect]:
|
|
252
|
+
"""{child id: effect} of one SendMessage request (T0, T3). A rejected request, and a queued
|
|
253
|
+
one, touch no child. `siblings` are the ids of the session's children: a `to` or
|
|
254
|
+
`recipient` that is none of them is an agent name (GA-7), never a contradiction."""
|
|
255
|
+
use = uses[0]
|
|
256
|
+
results = _distinct(facts.results.get(tool_id, []))
|
|
257
|
+
if results and all(r.is_error for r in results):
|
|
258
|
+
return {}
|
|
259
|
+
present = [v for v in (use.to, use.recipient) if v is not ABSENT]
|
|
260
|
+
strings = {v for v in present if isinstance(v, str)}
|
|
261
|
+
children = strings & siblings
|
|
262
|
+
# well formed: at least one target field, every present field a string, and no two of them
|
|
263
|
+
# naming different children (the runtime writes both `to` and `recipient`, with one value;
|
|
264
|
+
# a value that is no child's id is an agent name and contradicts nothing)
|
|
265
|
+
fields_bad = (
|
|
266
|
+
(not present) or any(not isinstance(v, str) for v in present) or len(children) > 1
|
|
267
|
+
)
|
|
268
|
+
if not results:
|
|
269
|
+
kind = "malformed" if fields_bad else "delivery_unknown"
|
|
270
|
+
return {c: _Effect(kind, tool_id, use.ts, use.line) for c in strings}
|
|
271
|
+
if len(results) > 1:
|
|
272
|
+
kind = "malformed" if fields_bad else "delivery_unknown"
|
|
273
|
+
return {c: _Effect(kind, tool_id, use.ts, use.line) for c in strings}
|
|
274
|
+
result = results[0]
|
|
275
|
+
resume_shape = (
|
|
276
|
+
result.tur_kind == "object" and result.success is True
|
|
277
|
+
and isinstance(result.resumed, str)
|
|
278
|
+
)
|
|
279
|
+
queued_shape = (
|
|
280
|
+
result.tur_kind == "object" and result.success is True and result.resumed is ABSENT
|
|
281
|
+
and isinstance(result.message, str) and result.message.startswith(QUEUED_PREFIX)
|
|
282
|
+
)
|
|
283
|
+
named = set(strings)
|
|
284
|
+
if isinstance(result.resumed, str):
|
|
285
|
+
named.add(result.resumed)
|
|
286
|
+
if fields_bad or not (resume_shape or queued_shape):
|
|
287
|
+
return {c: _Effect("malformed", tool_id, use.ts, use.line) for c in named}
|
|
288
|
+
if queued_shape:
|
|
289
|
+
return {}
|
|
290
|
+
if children - {result.resumed}:
|
|
291
|
+
return {c: _Effect("malformed", tool_id, use.ts, use.line) for c in named}
|
|
292
|
+
return {result.resumed: _Effect("resume", tool_id, use.ts, use.line)}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def resume_count(
|
|
296
|
+
child: str, issuers: list[ReturnFacts], siblings: frozenset[str] = frozenset(),
|
|
297
|
+
) -> int:
|
|
298
|
+
count = 0
|
|
299
|
+
for facts in issuers:
|
|
300
|
+
for tool_id, uses in _send_uses(facts):
|
|
301
|
+
effect = request_effects(facts, tool_id, uses, siblings).get(child)
|
|
302
|
+
if effect is not None and effect.kind == "resume":
|
|
303
|
+
count += 1
|
|
304
|
+
return count
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _launch_candidates(
|
|
308
|
+
child: str, launcher: ReturnFacts, notes: list[Note], meta_tool_use: object = ABSENT,
|
|
309
|
+
):
|
|
310
|
+
agent_uses = {
|
|
311
|
+
tool_id for tool_id, uses in launcher.uses.items()
|
|
312
|
+
if uses and uses[0].name in AGENT_LAUNCH_TOOLS
|
|
313
|
+
}
|
|
314
|
+
by_result = {
|
|
315
|
+
tool_id for tool_id in agent_uses
|
|
316
|
+
if any(child in r.agent_ids for r in launcher.results.get(tool_id, []))
|
|
317
|
+
}
|
|
318
|
+
by_note = {n.tool_use_id for n in notes if n.tool_use_id in agent_uses}
|
|
319
|
+
# the child's meta `toolUseId` (a string) names the launching tool_use in its launcher
|
|
320
|
+
by_meta = (
|
|
321
|
+
{meta_tool_use}
|
|
322
|
+
if isinstance(meta_tool_use, str) and meta_tool_use in agent_uses else set()
|
|
323
|
+
)
|
|
324
|
+
return by_result, by_note, by_meta
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main_side_digest(
|
|
328
|
+
child: str, main: ReturnFacts, notes: list[Note], meta_tool_use: object = ABSENT,
|
|
329
|
+
) -> str:
|
|
330
|
+
"""H1: a bounded digest of the main-side evidence for this child: its launch and resume
|
|
331
|
+
`tool_use` ids, lines, timestamps and result classes, and its accepted notifications' ids,
|
|
332
|
+
statuses and lines."""
|
|
333
|
+
facts: list = []
|
|
334
|
+
by_result, by_note, by_meta = _launch_candidates(child, main, notes, meta_tool_use)
|
|
335
|
+
for tool_id in sorted(by_result | by_note | by_meta):
|
|
336
|
+
use = main.uses[tool_id][0]
|
|
337
|
+
facts.append([
|
|
338
|
+
"launch", tool_id, use.line, use.ts.isoformat() if use.ts else None,
|
|
339
|
+
main.use_counts.get(tool_id, 0),
|
|
340
|
+
[
|
|
341
|
+
[r.line, r.is_error, r.tur_kind, sorted(r.agent_ids), r.bad_id, repr(r.status),
|
|
342
|
+
repr(r.is_async)]
|
|
343
|
+
for r in _distinct(main.results.get(tool_id, []))
|
|
344
|
+
],
|
|
345
|
+
])
|
|
346
|
+
for tool_id, uses in _send_uses(main):
|
|
347
|
+
use = uses[0]
|
|
348
|
+
present = {v for v in (use.to, use.recipient) if isinstance(v, str)}
|
|
349
|
+
results = _distinct(main.results.get(tool_id, []))
|
|
350
|
+
resumed = any(r.resumed == child for r in results)
|
|
351
|
+
if child not in present and not resumed:
|
|
352
|
+
continue
|
|
353
|
+
facts.append([
|
|
354
|
+
"request", tool_id, use.line, use.ts.isoformat() if use.ts else None,
|
|
355
|
+
main.use_counts.get(tool_id, 0), repr(use.to), repr(use.recipient),
|
|
356
|
+
[
|
|
357
|
+
[r.line, r.is_error, r.tur_kind, repr(r.success), repr(r.resumed), repr(r.message)]
|
|
358
|
+
for r in results
|
|
359
|
+
],
|
|
360
|
+
])
|
|
361
|
+
facts.append(["meta_link", meta_tool_use if isinstance(meta_tool_use, str) else None])
|
|
362
|
+
facts.append(["bad", child in main.bad_named])
|
|
363
|
+
facts.append(["notes", sorted((n.tool_use_id, n.status, n.line) for n in notes)])
|
|
364
|
+
text = json.dumps(facts, sort_keys=True, default=repr)
|
|
365
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def decide(
|
|
369
|
+
child: str, launcher: ReturnFacts, main: ReturnFacts, notes: list[Note],
|
|
370
|
+
issuers: list[ReturnFacts], siblings: frozenset[str] = frozenset(),
|
|
371
|
+
meta_tool_use: object = ABSENT,
|
|
372
|
+
) -> Decision:
|
|
373
|
+
"""T0 to T5 for `child` (T1's launcher choice is `launcher_of`; its failures never reach here).
|
|
374
|
+
|
|
375
|
+
`launcher` is the launcher transcript's facts (the main transcript's own when the child is
|
|
376
|
+
launched by the main thread), `main` the main transcript's, `notes` the accepted main
|
|
377
|
+
notifications whose task-id is the child, `issuers` every inspected transcript's facts that
|
|
378
|
+
may hold a resume request; `siblings` the ids of the session's children (GA-7)."""
|
|
379
|
+
resumes = resume_count(child, issuers, siblings)
|
|
380
|
+
|
|
381
|
+
def unknown(reason: str) -> Decision:
|
|
382
|
+
return Decision(UNKNOWN, reason, resumes)
|
|
383
|
+
|
|
384
|
+
# T0 (GA-4): a launch or request with a non-string tool_use id names this child
|
|
385
|
+
if any(child in f.bad_named for f in (launcher, main, *issuers)):
|
|
386
|
+
return unknown("malformed")
|
|
387
|
+
|
|
388
|
+
# T2: the launch
|
|
389
|
+
by_result, by_note, by_meta = _launch_candidates(child, launcher, notes, meta_tool_use)
|
|
390
|
+
candidates = by_result | by_note | by_meta
|
|
391
|
+
if not candidates:
|
|
392
|
+
return unknown("no_evidence")
|
|
393
|
+
if len(candidates) > 1:
|
|
394
|
+
return unknown("conflict")
|
|
395
|
+
launch_id = next(iter(candidates))
|
|
396
|
+
launch_use = launcher.uses[launch_id][0]
|
|
397
|
+
results = _distinct(launcher.results.get(launch_id, []))
|
|
398
|
+
if len(results) > 1:
|
|
399
|
+
return unknown("conflict")
|
|
400
|
+
|
|
401
|
+
sync_value: tuple[str, int] | None = None
|
|
402
|
+
if results:
|
|
403
|
+
result = results[0]
|
|
404
|
+
if (
|
|
405
|
+
result.is_error or result.tur_kind in ("string", "other") or result.bad_id
|
|
406
|
+
or (result.status is not ABSENT and not isinstance(result.status, str))
|
|
407
|
+
):
|
|
408
|
+
return unknown("malformed")
|
|
409
|
+
ids = set(result.agent_ids)
|
|
410
|
+
if ids and ids != {child}:
|
|
411
|
+
return unknown("conflict")
|
|
412
|
+
status = result.status
|
|
413
|
+
if result.is_async is True and status in _TERMINAL_WORDS:
|
|
414
|
+
return unknown("malformed")
|
|
415
|
+
asynchronous = (
|
|
416
|
+
status == "async_launched"
|
|
417
|
+
or (result.is_async is True and status is ABSENT)
|
|
418
|
+
or (not ids and launch_id in by_note)
|
|
419
|
+
)
|
|
420
|
+
if not asynchronous:
|
|
421
|
+
if status is ABSENT or status == "completed":
|
|
422
|
+
sync_value = (RETURNED, result.line)
|
|
423
|
+
elif status == "failed":
|
|
424
|
+
sync_value = (REPORTED_FAILED, result.line)
|
|
425
|
+
elif status in _STOPPED_WORDS:
|
|
426
|
+
sync_value = (REPORTED_KILLED, result.line)
|
|
427
|
+
else:
|
|
428
|
+
return unknown("status_unrecognized")
|
|
429
|
+
elif launch_id not in by_note:
|
|
430
|
+
return unknown("no_evidence")
|
|
431
|
+
|
|
432
|
+
# T3: requests and rounds
|
|
433
|
+
named: list[tuple[str, UseInfo, ReturnFacts, _Effect]] = []
|
|
434
|
+
for facts in issuers:
|
|
435
|
+
for tool_id, uses in _send_uses(facts):
|
|
436
|
+
effect = request_effects(facts, tool_id, uses, siblings).get(child)
|
|
437
|
+
if effect is not None:
|
|
438
|
+
named.append((tool_id, uses[0], facts, effect))
|
|
439
|
+
unique = {id(f): f for f in [launcher, *issuers]}.values()
|
|
440
|
+
|
|
441
|
+
def carried_once(tool_id: str) -> bool:
|
|
442
|
+
return sum(f.use_counts.get(tool_id, 0) for f in unique) == 1
|
|
443
|
+
|
|
444
|
+
if not carried_once(launch_id) or not all(carried_once(item[0]) for item in named):
|
|
445
|
+
return unknown("conflict")
|
|
446
|
+
for _tool_id, _use, _facts, effect in named:
|
|
447
|
+
if effect.kind != "resume":
|
|
448
|
+
return unknown(effect.kind)
|
|
449
|
+
request_rounds = [(tool_id, use, facts) for tool_id, use, facts, _e in named]
|
|
450
|
+
rounds: list[tuple[str, UseInfo, ReturnFacts]] = [(launch_id, launch_use, launcher)]
|
|
451
|
+
if request_rounds:
|
|
452
|
+
stamps = [
|
|
453
|
+
_aware(use.ts) if use.ts is not None else None
|
|
454
|
+
for _t, use, _f in rounds + request_rounds
|
|
455
|
+
]
|
|
456
|
+
if any(s is None for s in stamps) or len(set(stamps)) != len(stamps):
|
|
457
|
+
return unknown("ambiguous")
|
|
458
|
+
if any(s <= stamps[0] for s in stamps[1:]):
|
|
459
|
+
return unknown("ambiguous")
|
|
460
|
+
rounds = [rounds[0]] + [
|
|
461
|
+
r for _s, r in sorted(
|
|
462
|
+
zip(stamps[1:], request_rounds), key=lambda pair: pair[0]
|
|
463
|
+
)
|
|
464
|
+
]
|
|
465
|
+
round_by_id = {tool_id: (use, facts) for tool_id, use, facts in rounds}
|
|
466
|
+
|
|
467
|
+
# T4: notifications
|
|
468
|
+
for n in notes:
|
|
469
|
+
if n.tool_use_id not in round_by_id:
|
|
470
|
+
return unknown("conflict")
|
|
471
|
+
use, facts = round_by_id[n.tool_use_id]
|
|
472
|
+
if facts is main:
|
|
473
|
+
if n.line <= use.line:
|
|
474
|
+
return unknown("ambiguous")
|
|
475
|
+
elif n.ts is None or use.ts is None or _aware(n.ts) <= _aware(use.ts):
|
|
476
|
+
return unknown("ambiguous")
|
|
477
|
+
latest_id = rounds[-1][0]
|
|
478
|
+
in_latest = [n for n in notes if n.tool_use_id == latest_id]
|
|
479
|
+
|
|
480
|
+
# T5
|
|
481
|
+
words = {normalized_status(n.status) for n in in_latest}
|
|
482
|
+
if "unrecognized" in words:
|
|
483
|
+
return unknown("status_unrecognized")
|
|
484
|
+
if len(words) > 1:
|
|
485
|
+
return unknown("conflict")
|
|
486
|
+
note_state = _STATE_OF_WORD[next(iter(words))] if words else None
|
|
487
|
+
sync = sync_value if latest_id == launch_id else None
|
|
488
|
+
if sync is not None and note_state is not None and sync[0] != note_state:
|
|
489
|
+
return unknown("conflict")
|
|
490
|
+
decided, evidence = PENDING, None
|
|
491
|
+
if note_state is not None:
|
|
492
|
+
decided = note_state
|
|
493
|
+
evidence = ("notification", min(n.line for n in in_latest), False)
|
|
494
|
+
elif sync is not None:
|
|
495
|
+
decided = sync[0]
|
|
496
|
+
evidence = ("launch_result", sync[1], True)
|
|
497
|
+
if decided != REPORTED_FAILED:
|
|
498
|
+
evidence = None
|
|
499
|
+
return Decision(decided, "none", resumes, evidence)
|