metaensemble 0.2.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.
- evals/README.md +147 -0
- evals/__init__.py +0 -0
- evals/cassettes/README.md +10 -0
- evals/cassettes/bootstrap.jsonl +800 -0
- evals/configs/default.yaml +59 -0
- evals/datasets/__init__.py +0 -0
- evals/datasets/suite_a/tasks.yaml +123 -0
- evals/datasets/suite_b/items.yaml +90 -0
- evals/runners/__init__.py +12 -0
- evals/runners/api.py +518 -0
- evals/runners/metrics.py +132 -0
- metaensemble/__init__.py +13 -0
- metaensemble/cli.py +1362 -0
- metaensemble/commands/dispatch.md +39 -0
- metaensemble/commands/executors.md +12 -0
- metaensemble/commands/ledger.md +19 -0
- metaensemble/commands/limits.md +12 -0
- metaensemble/commands/perf.md +12 -0
- metaensemble/commands/relaunch.md +29 -0
- metaensemble/commands/standup.md +14 -0
- metaensemble/config/budgets.example.yaml +72 -0
- metaensemble/config/quality.example.yaml +82 -0
- metaensemble/hooks/__init__.py +1 -0
- metaensemble/hooks/_common.py +148 -0
- metaensemble/hooks/deliverable_sync.py +73 -0
- metaensemble/hooks/file_event.py +303 -0
- metaensemble/hooks/post_task.py +460 -0
- metaensemble/hooks/pre_task.py +548 -0
- metaensemble/hooks/session_start.py +212 -0
- metaensemble/hooks/session_summary.py +392 -0
- metaensemble/hooks/subagent_stop.py +94 -0
- metaensemble/lib/__init__.py +1 -0
- metaensemble/lib/config.py +414 -0
- metaensemble/lib/cost_gate.py +299 -0
- metaensemble/lib/dispatch.py +341 -0
- metaensemble/lib/doctor.py +1563 -0
- metaensemble/lib/file_events.py +395 -0
- metaensemble/lib/ids.py +91 -0
- metaensemble/lib/installer.py +5018 -0
- metaensemble/lib/ledger.py +812 -0
- metaensemble/lib/manifest.py +141 -0
- metaensemble/lib/native_state.py +463 -0
- metaensemble/lib/overlaps.py +155 -0
- metaensemble/lib/quality_gate.py +155 -0
- metaensemble/lib/quality_runners.py +446 -0
- metaensemble/lib/reconcile.py +420 -0
- metaensemble/lib/recording.py +422 -0
- metaensemble/lib/relaunch.py +174 -0
- metaensemble/lib/runtime_payload.py +42 -0
- metaensemble/lib/runtime_state.py +308 -0
- metaensemble/lib/sidecar.py +166 -0
- metaensemble/lib/topology.py +181 -0
- metaensemble/lib/transcript.py +432 -0
- metaensemble/output-styles/deliverable.md +33 -0
- metaensemble/output-styles/wire.md +38 -0
- metaensemble/roles/architect.md +52 -0
- metaensemble/roles/backend.md +43 -0
- metaensemble/roles/code-quality.md +49 -0
- metaensemble/roles/data-engineer.md +42 -0
- metaensemble/roles/devops.md +42 -0
- metaensemble/roles/docs.md +41 -0
- metaensemble/roles/frontend.md +42 -0
- metaensemble/roles/ml-engineer.md +42 -0
- metaensemble/roles/test-engineer.md +42 -0
- metaensemble/schemas/brief.schema.json +80 -0
- metaensemble/schemas/manifest.schema.json +142 -0
- metaensemble/schemas/role.schema.json +84 -0
- metaensemble/skills/metaensemble-protocol/SKILL.md +226 -0
- metaensemble/state/migrations/001_init.sql +72 -0
- metaensemble/state/migrations/002_outcome_extended.sql +86 -0
- metaensemble/state/migrations/003_run_provenance.sql +36 -0
- metaensemble/statusline/me_status.py +187 -0
- metaensemble/tools/__init__.py +7 -0
- metaensemble/tools/executors.py +62 -0
- metaensemble/tools/ledger.py +121 -0
- metaensemble/tools/limits.py +165 -0
- metaensemble/tools/perf.py +150 -0
- metaensemble/tools/standup.py +177 -0
- metaensemble/tools/stats.py +115 -0
- metaensemble-0.2.0.dist-info/METADATA +221 -0
- metaensemble-0.2.0.dist-info/RECORD +85 -0
- metaensemble-0.2.0.dist-info/WHEEL +5 -0
- metaensemble-0.2.0.dist-info/entry_points.txt +2 -0
- metaensemble-0.2.0.dist-info/licenses/LICENSE +21 -0
- metaensemble-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook for Task invocations.
|
|
3
|
+
|
|
4
|
+
PostToolUse completes the Run record that PreToolUse stamped. It reads
|
|
5
|
+
the pending-Run sidecar by session id, fills in the end-of-Run fields
|
|
6
|
+
(`ended_ts`, `tokens_out`, `outcome`, `deliverable_path`), writes the
|
|
7
|
+
completed Run to the Ledger, and removes the sidecar.
|
|
8
|
+
|
|
9
|
+
If no sidecar exists for this session — for example, because PreToolUse
|
|
10
|
+
blocked the Task or because the runtime invoked PostToolUse without a
|
|
11
|
+
matching PreToolUse — this hook logs the omission and exits 0. It never
|
|
12
|
+
blocks PostToolUse; the agent runtime has already produced the user-
|
|
13
|
+
visible result.
|
|
14
|
+
|
|
15
|
+
Hook contract:
|
|
16
|
+
- Stdin: agent runtime payload with `tool_name`, `tool_input`,
|
|
17
|
+
`tool_response`, `session_id`
|
|
18
|
+
- Stdout: JSON with `continue: true`
|
|
19
|
+
- Exit: 0 always
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
28
|
+
|
|
29
|
+
import json # noqa: E402
|
|
30
|
+
|
|
31
|
+
from metaensemble.hooks._common import ( # noqa: E402
|
|
32
|
+
db_path_for_state,
|
|
33
|
+
emit,
|
|
34
|
+
jsonl_path_for_state,
|
|
35
|
+
log_error,
|
|
36
|
+
migration_sql,
|
|
37
|
+
project_root_from_payload,
|
|
38
|
+
read_input,
|
|
39
|
+
state_dir,
|
|
40
|
+
state_dir_for_payload,
|
|
41
|
+
)
|
|
42
|
+
from metaensemble.lib.config import load_quality_config # noqa: E402
|
|
43
|
+
from metaensemble.lib.cost_gate import GateState # noqa: E402
|
|
44
|
+
from metaensemble.lib.file_events import ( # noqa: E402
|
|
45
|
+
ActiveDispatch,
|
|
46
|
+
clear_active_dispatch_for_run,
|
|
47
|
+
clear_file_events,
|
|
48
|
+
read_active_dispatch,
|
|
49
|
+
read_file_events,
|
|
50
|
+
write_active_dispatch_by_agent,
|
|
51
|
+
)
|
|
52
|
+
from metaensemble.lib.ledger import Executor, Ledger, Run # noqa: E402
|
|
53
|
+
from metaensemble.lib.manifest import load_manifest # noqa: E402
|
|
54
|
+
from metaensemble.lib.native_state import load_native_rate_limits # noqa: E402
|
|
55
|
+
from metaensemble.lib.quality_gate import build_decision # noqa: E402
|
|
56
|
+
from metaensemble.lib.quality_runners import run_all_axes # noqa: E402
|
|
57
|
+
from metaensemble.lib.recording import ( # noqa: E402
|
|
58
|
+
build_deliverable_ref,
|
|
59
|
+
classify_failure_reason,
|
|
60
|
+
classify_outcome,
|
|
61
|
+
coerce_to_text,
|
|
62
|
+
estimate_tokens,
|
|
63
|
+
extract_deliverable_path,
|
|
64
|
+
)
|
|
65
|
+
from metaensemble.lib.runtime_payload import normalize_model_identity # noqa: E402
|
|
66
|
+
from metaensemble.lib.sidecar import ( # noqa: E402
|
|
67
|
+
PendingRun,
|
|
68
|
+
delete_pending,
|
|
69
|
+
latest_pending_for_session,
|
|
70
|
+
pending_by_tool_use_id,
|
|
71
|
+
)
|
|
72
|
+
from metaensemble.lib.transcript import ( # noqa: E402
|
|
73
|
+
dominant_model,
|
|
74
|
+
transcript_path_for_session,
|
|
75
|
+
walk_transcript,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _quality_files_from_manifest(manifest_path: str | None) -> list[Path]:
|
|
80
|
+
"""Return every file declared in the Manifest's `expected_deliverables`.
|
|
81
|
+
Relevance is decided per axis downstream: the Python runners filter to
|
|
82
|
+
.py internally, and configured axis commands fire on non-Python
|
|
83
|
+
deliverables. Empty list means the quality gate skips."""
|
|
84
|
+
if not manifest_path:
|
|
85
|
+
return []
|
|
86
|
+
try:
|
|
87
|
+
manifest = load_manifest(manifest_path)
|
|
88
|
+
except Exception:
|
|
89
|
+
# Manifest invalid or missing — quality gate cannot evaluate.
|
|
90
|
+
return []
|
|
91
|
+
files: list[Path] = []
|
|
92
|
+
for entry in manifest.get("expected_deliverables", []):
|
|
93
|
+
path = entry.get("path")
|
|
94
|
+
if path:
|
|
95
|
+
files.append(Path(path))
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run_quality_gate(
|
|
100
|
+
manifest_path: str | None, project_root: Path
|
|
101
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
102
|
+
"""Run the quality gate on a Run's expected deliverables.
|
|
103
|
+
|
|
104
|
+
Returns `(quality_state, quality_findings_json, summary)` where each
|
|
105
|
+
is None when the gate does not run (no manifest, no evaluable
|
|
106
|
+
deliverables, config load failure, etc.). The summary is the
|
|
107
|
+
one-paragraph English block the hook surfaces to the Coordinator on
|
|
108
|
+
NOTIFY or BLOCK.
|
|
109
|
+
"""
|
|
110
|
+
files = _quality_files_from_manifest(manifest_path)
|
|
111
|
+
if not files:
|
|
112
|
+
return None, None, None
|
|
113
|
+
try:
|
|
114
|
+
config = load_quality_config()
|
|
115
|
+
if all(f.suffix != ".py" for f in files) and not config.axis_commands:
|
|
116
|
+
# Nothing the gate can evaluate: no Python deliverables for
|
|
117
|
+
# the built-in runners and no configured axis commands.
|
|
118
|
+
return None, None, None
|
|
119
|
+
axes = run_all_axes(files, config, project_root)
|
|
120
|
+
decision = build_decision(axes)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
log_error("quality-gate-failed", str(exc), {"manifest_path": manifest_path})
|
|
123
|
+
return None, None, None
|
|
124
|
+
|
|
125
|
+
findings_payload = {
|
|
126
|
+
"axes": [
|
|
127
|
+
{
|
|
128
|
+
"name": a.name,
|
|
129
|
+
"state": a.state.value,
|
|
130
|
+
"findings": list(a.findings),
|
|
131
|
+
"raw": a.raw,
|
|
132
|
+
}
|
|
133
|
+
for a in decision.axes
|
|
134
|
+
],
|
|
135
|
+
"options": list(decision.options),
|
|
136
|
+
}
|
|
137
|
+
summary = decision.summary if decision.state != GateState.AUTO else None
|
|
138
|
+
return decision.state.value, json.dumps(findings_payload), summary
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def finalize_pending(
|
|
142
|
+
pending: PendingRun,
|
|
143
|
+
*,
|
|
144
|
+
run_state_dir: Path,
|
|
145
|
+
project_root: Path,
|
|
146
|
+
response_text: str,
|
|
147
|
+
outcome: str,
|
|
148
|
+
failure_reason: str | None,
|
|
149
|
+
deliverable_path: str | None,
|
|
150
|
+
transcript_path: str | None,
|
|
151
|
+
session_id: str,
|
|
152
|
+
) -> str | None:
|
|
153
|
+
"""Complete a pending Run and write it to the Ledger.
|
|
154
|
+
|
|
155
|
+
Shared by PostToolUse (synchronous runtimes) and SubagentStop (background
|
|
156
|
+
runtimes) so both produce identical Run records. Idempotent at the call
|
|
157
|
+
site: callers invoke it only when a pending sidecar was found, and it
|
|
158
|
+
deletes the sidecar at the end, so a second finalize finds no pending.
|
|
159
|
+
Active-dispatch markers are cleared by run_id across both session and
|
|
160
|
+
agentId indexes, so a completed run cannot keep writes authorized.
|
|
161
|
+
Returns the quality-gate summary to surface, or None.
|
|
162
|
+
"""
|
|
163
|
+
ledger = Ledger(
|
|
164
|
+
db_path=db_path_for_state(run_state_dir),
|
|
165
|
+
jsonl_path=jsonl_path_for_state(run_state_dir),
|
|
166
|
+
)
|
|
167
|
+
ledger.initialize(migration_sql())
|
|
168
|
+
|
|
169
|
+
tokens_out = estimate_tokens(response_text)
|
|
170
|
+
ended_ts = datetime.now(timezone.utc).isoformat()
|
|
171
|
+
|
|
172
|
+
quality_state, quality_findings_json, quality_summary = (None, None, None)
|
|
173
|
+
if outcome == "ok":
|
|
174
|
+
quality_state, quality_findings_json, quality_summary = _run_quality_gate(
|
|
175
|
+
pending.manifest_path, project_root
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
harvest = None
|
|
179
|
+
resolved_transcript_path = (
|
|
180
|
+
Path(transcript_path)
|
|
181
|
+
if transcript_path
|
|
182
|
+
else transcript_path_for_session(session_id, cwd=Path.cwd())
|
|
183
|
+
)
|
|
184
|
+
if resolved_transcript_path:
|
|
185
|
+
try:
|
|
186
|
+
harvest = walk_transcript(
|
|
187
|
+
resolved_transcript_path,
|
|
188
|
+
after_ts=pending.started_ts,
|
|
189
|
+
before_ts=ended_ts,
|
|
190
|
+
dispatch_task_id=pending.task_id,
|
|
191
|
+
dispatch_role_id=pending.role_id,
|
|
192
|
+
dispatch_prompt_sha256=(pending.extra or {}).get("tool_prompt_sha256"),
|
|
193
|
+
dispatch_started_ts=pending.started_ts,
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
log_error(
|
|
197
|
+
"post-task-transcript-walk-failed",
|
|
198
|
+
str(exc),
|
|
199
|
+
{"run_id": pending.run_id, "transcript_path": str(resolved_transcript_path)},
|
|
200
|
+
)
|
|
201
|
+
harvest = None
|
|
202
|
+
|
|
203
|
+
event_records = read_file_events(
|
|
204
|
+
run_state_dir,
|
|
205
|
+
run_id=pending.run_id,
|
|
206
|
+
session_id=pending.session_id,
|
|
207
|
+
started_ts=pending.started_ts,
|
|
208
|
+
ended_ts=ended_ts,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
touched: set[str] = set()
|
|
212
|
+
tool_counts: dict[str, int] = {}
|
|
213
|
+
tool_input_tokens: dict[str, int] = {}
|
|
214
|
+
files_touched_json: str | None = None
|
|
215
|
+
tool_use_json: str | None = None
|
|
216
|
+
cache_read = 0
|
|
217
|
+
cache_create = 0
|
|
218
|
+
runtime_model = None
|
|
219
|
+
model_source = "tier_fallback"
|
|
220
|
+
if harvest is not None:
|
|
221
|
+
if harvest.files_touched:
|
|
222
|
+
touched.update(harvest.files_touched)
|
|
223
|
+
if harvest.tool_use:
|
|
224
|
+
for t in harvest.tool_use:
|
|
225
|
+
tool_counts[t.name] = tool_counts.get(t.name, 0) + t.count
|
|
226
|
+
tool_input_tokens[t.name] = (
|
|
227
|
+
tool_input_tokens.get(t.name, 0) + t.total_input_tokens
|
|
228
|
+
)
|
|
229
|
+
cache_read = harvest.cache_read_tokens
|
|
230
|
+
cache_create = harvest.cache_create_tokens
|
|
231
|
+
runtime_model = normalize_model_identity(dominant_model(harvest))
|
|
232
|
+
if runtime_model:
|
|
233
|
+
model_source = "transcript"
|
|
234
|
+
|
|
235
|
+
if runtime_model is None:
|
|
236
|
+
native = load_native_rate_limits()
|
|
237
|
+
if (
|
|
238
|
+
native is not None
|
|
239
|
+
and native.is_fresh
|
|
240
|
+
and native.model
|
|
241
|
+
and native.session_id
|
|
242
|
+
and native.session_id in {pending.session_id, session_id}
|
|
243
|
+
):
|
|
244
|
+
runtime_model = normalize_model_identity(native.model)
|
|
245
|
+
model_source = "statusline"
|
|
246
|
+
|
|
247
|
+
# Avoid double-counting tools already captured from the transcript harvest;
|
|
248
|
+
# file events still always contribute to files_touched.
|
|
249
|
+
harvest_has_tools = harvest is not None and bool(harvest.tool_use)
|
|
250
|
+
for event in event_records:
|
|
251
|
+
touched.add(event.rel_path or event.path)
|
|
252
|
+
if not harvest_has_tools:
|
|
253
|
+
tool_counts[event.tool_name] = tool_counts.get(event.tool_name, 0) + 1
|
|
254
|
+
|
|
255
|
+
# Defense-in-depth: only real artifacts are provenance. Drop any path that
|
|
256
|
+
# does not exist at finalization (a denied/attempted write the transcript
|
|
257
|
+
# may still list, or a path parsed from prose), and never record a missing
|
|
258
|
+
# file as a deliverable. The gap is logged as a diagnostic, not a deliverable.
|
|
259
|
+
def _output_exists(pth: str) -> bool:
|
|
260
|
+
candidate = Path(pth)
|
|
261
|
+
return (
|
|
262
|
+
candidate.exists() if candidate.is_absolute()
|
|
263
|
+
else (project_root / pth).exists()
|
|
264
|
+
)
|
|
265
|
+
missing_outputs = sorted(p for p in touched if not _output_exists(p))
|
|
266
|
+
touched = {p for p in touched if _output_exists(p)}
|
|
267
|
+
if deliverable_path and not _output_exists(deliverable_path):
|
|
268
|
+
deliverable_path = None
|
|
269
|
+
if missing_outputs:
|
|
270
|
+
log_error(
|
|
271
|
+
"finalize-missing-outputs",
|
|
272
|
+
"attempted outputs not found on disk; excluded from provenance",
|
|
273
|
+
{"run_id": pending.run_id, "missing": missing_outputs[:10]},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if touched:
|
|
277
|
+
files_touched_json = json.dumps(sorted(touched))
|
|
278
|
+
if tool_counts:
|
|
279
|
+
tool_use_json = json.dumps([
|
|
280
|
+
{"name": name, "count": count, "input_tokens": tool_input_tokens.get(name, 0)}
|
|
281
|
+
for name, count in sorted(tool_counts.items())
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
recorded_model = (
|
|
285
|
+
normalize_model_identity(runtime_model)
|
|
286
|
+
or normalize_model_identity(pending.model_tier)
|
|
287
|
+
or "unknown"
|
|
288
|
+
)
|
|
289
|
+
role = ledger.get_role(pending.role_id)
|
|
290
|
+
role_version = role.version if role else None
|
|
291
|
+
|
|
292
|
+
deliverable_ref = build_deliverable_ref(
|
|
293
|
+
response_text,
|
|
294
|
+
deliverable_path=deliverable_path,
|
|
295
|
+
files_touched=tuple(sorted(touched)),
|
|
296
|
+
)
|
|
297
|
+
deliverable_ref_json = json.dumps(deliverable_ref) if deliverable_ref else None
|
|
298
|
+
review_findings_json = quality_findings_json or None
|
|
299
|
+
|
|
300
|
+
run_record = Run(
|
|
301
|
+
run_id=pending.run_id,
|
|
302
|
+
executor_id=pending.executor_id,
|
|
303
|
+
task_id=pending.task_id,
|
|
304
|
+
model=recorded_model,
|
|
305
|
+
tokens_in=pending.estimated_tokens_in,
|
|
306
|
+
tokens_out=tokens_out,
|
|
307
|
+
window_id=pending.window_id,
|
|
308
|
+
started_ts=pending.started_ts,
|
|
309
|
+
ended_ts=ended_ts,
|
|
310
|
+
outcome=outcome,
|
|
311
|
+
brief_in_path=pending.brief_in_path,
|
|
312
|
+
brief_out_path=None,
|
|
313
|
+
deliverable_path=deliverable_path,
|
|
314
|
+
failure_reason=failure_reason,
|
|
315
|
+
quality_state=quality_state,
|
|
316
|
+
quality_findings_json=quality_findings_json,
|
|
317
|
+
role_version=role_version,
|
|
318
|
+
requested_model_tier=pending.model_tier,
|
|
319
|
+
model_source=model_source,
|
|
320
|
+
deliverable_ref_json=deliverable_ref_json,
|
|
321
|
+
files_touched_json=files_touched_json,
|
|
322
|
+
tool_use_json=tool_use_json,
|
|
323
|
+
review_findings_json=review_findings_json,
|
|
324
|
+
cache_read_tokens=cache_read,
|
|
325
|
+
cache_create_tokens=cache_create,
|
|
326
|
+
)
|
|
327
|
+
ledger.append_run(run_record)
|
|
328
|
+
|
|
329
|
+
existing = ledger.get_executor(pending.executor_id)
|
|
330
|
+
if existing:
|
|
331
|
+
ledger.upsert_executor(
|
|
332
|
+
Executor(
|
|
333
|
+
executor_id=existing.executor_id,
|
|
334
|
+
alias=existing.alias,
|
|
335
|
+
role_id=existing.role_id,
|
|
336
|
+
parent_executor_id=existing.parent_executor_id,
|
|
337
|
+
created_ts=existing.created_ts,
|
|
338
|
+
last_seen_ts=ended_ts,
|
|
339
|
+
status=existing.status,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
if outcome in {"ok", "partial"}:
|
|
343
|
+
ledger.update_task_status(pending.task_id, "done")
|
|
344
|
+
|
|
345
|
+
ledger.close()
|
|
346
|
+
delete_pending(run_state_dir, pending.run_id)
|
|
347
|
+
# Clear EVERY marker pointing at this run (session- and agentId-keyed), so a
|
|
348
|
+
# completed Run can never keep a coordinator/direct write authorized.
|
|
349
|
+
clear_active_dispatch_for_run(pending.run_id, session_id=pending.session_id)
|
|
350
|
+
clear_file_events(run_state_dir, run_id=pending.run_id, events=event_records)
|
|
351
|
+
return quality_summary
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def run() -> int:
|
|
355
|
+
payload = read_input()
|
|
356
|
+
if payload.get("tool_name") not in ("Task", "Agent"):
|
|
357
|
+
emit({"continue": True})
|
|
358
|
+
return 0
|
|
359
|
+
tool_response = payload.get("tool_response") or payload.get("tool_output")
|
|
360
|
+
|
|
361
|
+
# --- Background Agent path --------------------------------------------
|
|
362
|
+
# The Agent tool returns a launch stub carrying the runtime agentId before
|
|
363
|
+
# the subagent has done anything. Reconcile it to the pre_task stamp by
|
|
364
|
+
# tool_use_id, record an agentId-keyed active dispatch (so the subagent's
|
|
365
|
+
# writes are authorized and SubagentStop can finalize by agent_id), and
|
|
366
|
+
# DEFER finalization. agentId is absent on synchronous runtimes.
|
|
367
|
+
agent_id = tool_response.get("agentId") if isinstance(tool_response, dict) else None
|
|
368
|
+
if payload.get("tool_name") == "Agent" and agent_id:
|
|
369
|
+
tool_use_id = payload.get("tool_use_id")
|
|
370
|
+
bg_session = payload.get("session_id") or ""
|
|
371
|
+
bg_state_dir = state_dir_for_payload(payload)
|
|
372
|
+
bg_root = project_root_from_payload(payload) or Path.cwd().resolve(strict=False)
|
|
373
|
+
# Cross-project dispatches: the launch payload may lack the original
|
|
374
|
+
# [project:] context, so consult the session marker pre_task wrote
|
|
375
|
+
# (carries the dispatch's true project_root/state_dir) first.
|
|
376
|
+
bg_active = read_active_dispatch(bg_session) if bg_session else None
|
|
377
|
+
if bg_active is not None and bg_state_dir == state_dir():
|
|
378
|
+
bg_state_dir = Path(bg_active.state_dir)
|
|
379
|
+
bg_root = Path(bg_active.project_root)
|
|
380
|
+
pending = pending_by_tool_use_id(bg_state_dir, tool_use_id) if tool_use_id else None
|
|
381
|
+
if pending is not None:
|
|
382
|
+
try:
|
|
383
|
+
write_active_dispatch_by_agent(
|
|
384
|
+
ActiveDispatch(
|
|
385
|
+
session_id=payload.get("session_id") or "",
|
|
386
|
+
run_id=pending.run_id,
|
|
387
|
+
project_root=str(bg_root.resolve(strict=False)),
|
|
388
|
+
state_dir=str(bg_state_dir.resolve(strict=False)),
|
|
389
|
+
started_ts=pending.started_ts,
|
|
390
|
+
agent_id=agent_id,
|
|
391
|
+
tool_use_id=tool_use_id,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
log_error(
|
|
396
|
+
"post-task-agent-index-failed",
|
|
397
|
+
str(exc),
|
|
398
|
+
{"run_id": pending.run_id, "agent_id": agent_id},
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
log_error(
|
|
402
|
+
"post-task-no-pending-for-tooluse",
|
|
403
|
+
"background launch with no matching pending sidecar",
|
|
404
|
+
{"tool_use_id": tool_use_id, "agent_id": agent_id},
|
|
405
|
+
)
|
|
406
|
+
emit({"continue": True})
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
# --- Synchronous path -------------------------------------------------
|
|
410
|
+
session_id = payload.get("session_id") or "unknown-session"
|
|
411
|
+
transcript_path = payload.get("transcript_path")
|
|
412
|
+
run_state_dir = state_dir_for_payload(payload)
|
|
413
|
+
project_root = project_root_from_payload(payload) or Path.cwd().resolve(strict=False)
|
|
414
|
+
active = read_active_dispatch(session_id)
|
|
415
|
+
if active is not None and run_state_dir == state_dir():
|
|
416
|
+
run_state_dir = Path(active.state_dir)
|
|
417
|
+
project_root = Path(active.project_root)
|
|
418
|
+
|
|
419
|
+
pending = latest_pending_for_session(run_state_dir, session_id)
|
|
420
|
+
if pending is None:
|
|
421
|
+
log_error(
|
|
422
|
+
"post-task-no-pending",
|
|
423
|
+
"no pending-Run sidecar matched this session",
|
|
424
|
+
{"session_id": session_id},
|
|
425
|
+
)
|
|
426
|
+
emit({"continue": True})
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
response_text = coerce_to_text(tool_response)
|
|
431
|
+
outcome = classify_outcome(tool_response)
|
|
432
|
+
failure_reason = (
|
|
433
|
+
classify_failure_reason(tool_response) if outcome == "failed" else None
|
|
434
|
+
)
|
|
435
|
+
deliverable_path = extract_deliverable_path(tool_response)
|
|
436
|
+
quality_summary = finalize_pending(
|
|
437
|
+
pending,
|
|
438
|
+
run_state_dir=run_state_dir,
|
|
439
|
+
project_root=project_root,
|
|
440
|
+
response_text=response_text,
|
|
441
|
+
outcome=outcome,
|
|
442
|
+
failure_reason=failure_reason,
|
|
443
|
+
deliverable_path=deliverable_path,
|
|
444
|
+
transcript_path=transcript_path,
|
|
445
|
+
session_id=session_id,
|
|
446
|
+
)
|
|
447
|
+
except Exception as exc:
|
|
448
|
+
log_error("post-task-failed", str(exc), {"run_id": pending.run_id})
|
|
449
|
+
emit({"continue": True})
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
if quality_summary:
|
|
453
|
+
emit({"continue": True, "systemMessage": quality_summary})
|
|
454
|
+
else:
|
|
455
|
+
emit({"continue": True})
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
if __name__ == "__main__":
|
|
460
|
+
sys.exit(run())
|