simjecture 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.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
"""Read-only projection of durable natural-language MVP run artifacts.
|
|
2
|
+
|
|
3
|
+
The monitor never writes to a run directory. Claim ledgers and terminal reports
|
|
4
|
+
remain authoritative; model prose is only used to name the current typed action.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, TextIO
|
|
20
|
+
|
|
21
|
+
from pydantic import Field
|
|
22
|
+
|
|
23
|
+
from .models import StrictModel
|
|
24
|
+
from .mvp_agent import (
|
|
25
|
+
MVPAgentAction,
|
|
26
|
+
MVPAuthorAndRunCapabilityAction,
|
|
27
|
+
MVPCloseClaimAction,
|
|
28
|
+
MVPFinishAction,
|
|
29
|
+
MVPLinkClaimEvidenceAction,
|
|
30
|
+
MVPListFilesAction,
|
|
31
|
+
MVPListSkillsAction,
|
|
32
|
+
MVPMaterializeSkillResourceAction,
|
|
33
|
+
MVPReadFileAction,
|
|
34
|
+
MVPReadSkillAction,
|
|
35
|
+
MVPRegisterClaimAction,
|
|
36
|
+
MVPRegisterEvidenceContractAction,
|
|
37
|
+
MVPRunCapabilityAction,
|
|
38
|
+
MVPRunPythonAction,
|
|
39
|
+
MVPSearchLiteratureAction,
|
|
40
|
+
MVPWriteFileAction,
|
|
41
|
+
parse_mvp_action,
|
|
42
|
+
)
|
|
43
|
+
from .mvp_control import (
|
|
44
|
+
ControlCommand,
|
|
45
|
+
elapsed_from_clock,
|
|
46
|
+
format_token_counts,
|
|
47
|
+
parse_usage_payload,
|
|
48
|
+
read_clock,
|
|
49
|
+
read_control,
|
|
50
|
+
read_pause_state,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
TERMINAL_REPORT_STATUSES = frozenset(
|
|
54
|
+
{"completed", "cancelled", "provider_failed", "budget_exhausted"}
|
|
55
|
+
)
|
|
56
|
+
OPERATOR_INPUT_DIR = "operator_input"
|
|
57
|
+
DEFAULT_RECENT_EVENT_LIMIT = 40
|
|
58
|
+
_MAX_RETAINED_EVENTS = 80
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RunPhase(StrEnum):
|
|
62
|
+
"""Operator-facing phase derived from durable artifacts, not process liveness."""
|
|
63
|
+
|
|
64
|
+
INITIALIZED = "initialized"
|
|
65
|
+
INCOMPLETE = "incomplete"
|
|
66
|
+
PAUSED = "paused"
|
|
67
|
+
COMPLETED = "completed"
|
|
68
|
+
CANCELLED = "cancelled"
|
|
69
|
+
PROVIDER_FAILED = "provider_failed"
|
|
70
|
+
BUDGET_EXHAUSTED = "budget_exhausted"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TranscriptCursor(StrictModel):
|
|
74
|
+
"""Byte-oriented resume point for an append-only transcript."""
|
|
75
|
+
|
|
76
|
+
inode: int | None = None
|
|
77
|
+
offset: int = Field(default=0, ge=0)
|
|
78
|
+
size: int = Field(default=0, ge=0)
|
|
79
|
+
mtime_ns: int | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RunIdentity(StrictModel):
|
|
83
|
+
run_directory: str
|
|
84
|
+
campaign_id: str | None = None
|
|
85
|
+
hypothesis: str | None = None
|
|
86
|
+
campaign_instruction: str | None = None
|
|
87
|
+
manifest_schema_version: str | None = None
|
|
88
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
89
|
+
skill_hashes: dict[str, str] = Field(default_factory=dict)
|
|
90
|
+
capability_hashes: dict[str, str] = Field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ClaimSummary(StrictModel):
|
|
94
|
+
id: str
|
|
95
|
+
status: str
|
|
96
|
+
kind: str | None = None
|
|
97
|
+
relation: str | None = None
|
|
98
|
+
parent_id: str | None = None
|
|
99
|
+
statement: str = ""
|
|
100
|
+
evidence_count: int = Field(default=0, ge=0)
|
|
101
|
+
contract_count: int = Field(default=0, ge=0)
|
|
102
|
+
closed_reason: str | None = None
|
|
103
|
+
active: bool = False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CurrentAction(StrictModel):
|
|
107
|
+
iteration: int = Field(ge=1)
|
|
108
|
+
action_name: str | None = None
|
|
109
|
+
description: str
|
|
110
|
+
pending: bool
|
|
111
|
+
capability: str | None = None
|
|
112
|
+
stage: str | None = None
|
|
113
|
+
active_claim_id: str | None = None
|
|
114
|
+
path: str | None = None
|
|
115
|
+
model: str | None = None
|
|
116
|
+
route: str | None = None
|
|
117
|
+
argv: tuple[str, ...] = ()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HeartbeatObservation(StrictModel):
|
|
121
|
+
iteration: int = Field(ge=1)
|
|
122
|
+
elapsed_wall_seconds: float | None = None
|
|
123
|
+
stdout_bytes: int | None = None
|
|
124
|
+
stderr_bytes: int | None = None
|
|
125
|
+
workspace_bytes: int | None = None
|
|
126
|
+
observed_at: datetime | None = None
|
|
127
|
+
age_seconds: float | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HumanizedEvent(StrictModel):
|
|
131
|
+
sequence: int = Field(default=0, ge=0)
|
|
132
|
+
kind: str
|
|
133
|
+
iteration: int | None = None
|
|
134
|
+
summary: str
|
|
135
|
+
action_name: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ModelTokenUsage(StrictModel):
|
|
139
|
+
model: str
|
|
140
|
+
prompt_tokens: int = Field(default=0, ge=0)
|
|
141
|
+
completion_tokens: int = Field(default=0, ge=0)
|
|
142
|
+
total_tokens: int = Field(default=0, ge=0)
|
|
143
|
+
turns: int = Field(default=0, ge=0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TokenUsageSummary(StrictModel):
|
|
147
|
+
prompt_tokens: int = Field(default=0, ge=0)
|
|
148
|
+
completion_tokens: int = Field(default=0, ge=0)
|
|
149
|
+
total_tokens: int = Field(default=0, ge=0)
|
|
150
|
+
cached_tokens: int = Field(default=0, ge=0)
|
|
151
|
+
reasoning_tokens: int = Field(default=0, ge=0)
|
|
152
|
+
turns: int = Field(default=0, ge=0)
|
|
153
|
+
turns_missing_usage: int = Field(default=0, ge=0)
|
|
154
|
+
by_model: tuple[ModelTokenUsage, ...] = ()
|
|
155
|
+
label: str = "tokens: 0 in / 0 out / 0 total (0 turns)"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TerminalReportSummary(StrictModel):
|
|
159
|
+
status: str
|
|
160
|
+
final_answer: str
|
|
161
|
+
iterations: int = Field(ge=0)
|
|
162
|
+
elapsed_wall_seconds: float = Field(ge=0)
|
|
163
|
+
started_at: datetime | None = None
|
|
164
|
+
finished_at: datetime | None = None
|
|
165
|
+
open_claim_ids: tuple[str, ...] = ()
|
|
166
|
+
closed_claim_ids: tuple[str, ...] = ()
|
|
167
|
+
finish_claim_notes: tuple[str, ...] = ()
|
|
168
|
+
workspace_artifact_count: int = Field(default=0, ge=0)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ArtifactPaths(StrictModel):
|
|
172
|
+
run_directory: str
|
|
173
|
+
manifest: str | None = None
|
|
174
|
+
transcript: str | None = None
|
|
175
|
+
report: str | None = None
|
|
176
|
+
claim_ledger: str | None = None
|
|
177
|
+
claim_summary: str | None = None
|
|
178
|
+
artifact_provenance: str | None = None
|
|
179
|
+
workspace: str | None = None
|
|
180
|
+
controller_log: str | None = None
|
|
181
|
+
operator_input: str | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ContainedArtifact(StrictModel):
|
|
185
|
+
relative_path: str
|
|
186
|
+
bytes: int = Field(ge=0)
|
|
187
|
+
kind: str
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class RecentRun(StrictModel):
|
|
191
|
+
run_directory: str
|
|
192
|
+
campaign_id: str | None = None
|
|
193
|
+
phase: RunPhase
|
|
194
|
+
hypothesis: str | None = None
|
|
195
|
+
modified_at: datetime | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class MVPRunSnapshot(StrictModel):
|
|
199
|
+
phase: RunPhase
|
|
200
|
+
phase_label: str
|
|
201
|
+
identity: RunIdentity
|
|
202
|
+
configured_wall_seconds: float | None = None
|
|
203
|
+
elapsed_wall_seconds: float | None = None
|
|
204
|
+
elapsed_is_estimate: bool = False
|
|
205
|
+
iterations: int = Field(default=0, ge=0)
|
|
206
|
+
action_counts: dict[str, int] = Field(default_factory=dict)
|
|
207
|
+
claims: tuple[ClaimSummary, ...] = ()
|
|
208
|
+
open_claim_ids: tuple[str, ...] = ()
|
|
209
|
+
closed_claim_ids: tuple[str, ...] = ()
|
|
210
|
+
current_action: CurrentAction | None = None
|
|
211
|
+
latest_heartbeat: HeartbeatObservation | None = None
|
|
212
|
+
recent_events: tuple[HumanizedEvent, ...] = ()
|
|
213
|
+
report: TerminalReportSummary | None = None
|
|
214
|
+
artifacts: ArtifactPaths
|
|
215
|
+
workspace_bytes: int = Field(default=0, ge=0)
|
|
216
|
+
last_model: str | None = None
|
|
217
|
+
last_capability: str | None = None
|
|
218
|
+
token_usage: TokenUsageSummary = Field(default_factory=TokenUsageSummary)
|
|
219
|
+
pending_control: str | None = None
|
|
220
|
+
warnings: tuple[str, ...] = ()
|
|
221
|
+
transcript_cursor: TranscriptCursor = Field(default_factory=TranscriptCursor)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class _TranscriptState:
|
|
226
|
+
assistant: dict[int, dict[str, Any]] = field(default_factory=dict)
|
|
227
|
+
tools: dict[int, dict[str, Any]] = field(default_factory=dict)
|
|
228
|
+
last_heartbeat: dict[str, Any] | None = None
|
|
229
|
+
last_heartbeat_observed_at: datetime | None = None
|
|
230
|
+
events: list[HumanizedEvent] = field(default_factory=list)
|
|
231
|
+
action_counts: Counter[str] = field(default_factory=Counter)
|
|
232
|
+
last_model: str | None = None
|
|
233
|
+
last_route: str | None = None
|
|
234
|
+
last_capability: str | None = None
|
|
235
|
+
parse_warnings: list[str] = field(default_factory=list)
|
|
236
|
+
prompt_tokens: int = 0
|
|
237
|
+
completion_tokens: int = 0
|
|
238
|
+
total_tokens: int = 0
|
|
239
|
+
cached_tokens: int = 0
|
|
240
|
+
reasoning_tokens: int = 0
|
|
241
|
+
usage_turns: int = 0
|
|
242
|
+
missing_usage_turns: int = 0
|
|
243
|
+
model_usage: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
244
|
+
next_event_sequence: int = 0
|
|
245
|
+
|
|
246
|
+
def reset(self) -> None:
|
|
247
|
+
self.assistant.clear()
|
|
248
|
+
self.tools.clear()
|
|
249
|
+
self.last_heartbeat = None
|
|
250
|
+
self.last_heartbeat_observed_at = None
|
|
251
|
+
self.events.clear()
|
|
252
|
+
self.action_counts.clear()
|
|
253
|
+
self.last_model = None
|
|
254
|
+
self.last_route = None
|
|
255
|
+
self.last_capability = None
|
|
256
|
+
self.parse_warnings.clear()
|
|
257
|
+
self.prompt_tokens = 0
|
|
258
|
+
self.completion_tokens = 0
|
|
259
|
+
self.total_tokens = 0
|
|
260
|
+
self.cached_tokens = 0
|
|
261
|
+
self.reasoning_tokens = 0
|
|
262
|
+
self.usage_turns = 0
|
|
263
|
+
self.missing_usage_turns = 0
|
|
264
|
+
self.model_usage.clear()
|
|
265
|
+
self.next_event_sequence = 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def utc_now() -> datetime:
|
|
269
|
+
return datetime.now(UTC)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_duration(seconds: float | None) -> str:
|
|
273
|
+
if seconds is None:
|
|
274
|
+
return "--:--:--"
|
|
275
|
+
total = max(0, int(seconds))
|
|
276
|
+
hours, remainder = divmod(total, 3600)
|
|
277
|
+
minutes, secs = divmod(remainder, 60)
|
|
278
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def format_bytes(size: int | None) -> str:
|
|
282
|
+
if size is None:
|
|
283
|
+
return "unknown"
|
|
284
|
+
value = float(max(0, size))
|
|
285
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
286
|
+
if value < 1024.0 or unit == "TB":
|
|
287
|
+
if unit == "B":
|
|
288
|
+
return f"{int(value)} B"
|
|
289
|
+
return f"{value:.1f} {unit}"
|
|
290
|
+
value /= 1024.0
|
|
291
|
+
return f"{size} B"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def format_age(seconds: float | None) -> str:
|
|
295
|
+
if seconds is None:
|
|
296
|
+
return "unknown"
|
|
297
|
+
if seconds < 1:
|
|
298
|
+
return "just now"
|
|
299
|
+
if seconds < 60:
|
|
300
|
+
return f"{int(seconds)} s ago"
|
|
301
|
+
if seconds < 3600:
|
|
302
|
+
return f"{int(seconds // 60)} min ago"
|
|
303
|
+
return f"{int(seconds // 3600)} h ago"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def claim_status_marker(status: str) -> str:
|
|
307
|
+
markers = {
|
|
308
|
+
"open": "●",
|
|
309
|
+
"supported": "✓",
|
|
310
|
+
"weakened": "~",
|
|
311
|
+
"falsified": "×",
|
|
312
|
+
"superseded": "▸",
|
|
313
|
+
"unresolved": "?",
|
|
314
|
+
"instrument_limited": "!",
|
|
315
|
+
}
|
|
316
|
+
return markers.get(status, "·")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def phase_label(phase: RunPhase) -> str:
|
|
320
|
+
if phase is RunPhase.INCOMPLETE:
|
|
321
|
+
return "incomplete (no terminal report)"
|
|
322
|
+
if phase is RunPhase.INITIALIZED:
|
|
323
|
+
return "initialized"
|
|
324
|
+
if phase is RunPhase.PAUSED:
|
|
325
|
+
return "paused (action boundary)"
|
|
326
|
+
return phase.value
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def humanize_action(action: MVPAgentAction) -> str:
|
|
330
|
+
"""Describe a typed action without treating research notes as scientific status."""
|
|
331
|
+
|
|
332
|
+
if isinstance(action, MVPSearchLiteratureAction):
|
|
333
|
+
return f"Searching literature for {action.query!r}"
|
|
334
|
+
if isinstance(action, MVPWriteFileAction):
|
|
335
|
+
return f"Writing {action.path}"
|
|
336
|
+
if isinstance(action, MVPReadFileAction):
|
|
337
|
+
return f"Reading {action.path}"
|
|
338
|
+
if isinstance(action, MVPListFilesAction):
|
|
339
|
+
return f"Listing {action.path}"
|
|
340
|
+
if isinstance(action, MVPRunPythonAction):
|
|
341
|
+
command = _short_argv(action.argv)
|
|
342
|
+
if action.active_claim_id:
|
|
343
|
+
return f"Running Python {command} for {action.active_claim_id}"
|
|
344
|
+
return f"Running Python {command}"
|
|
345
|
+
if isinstance(action, MVPListSkillsAction):
|
|
346
|
+
return "Listing installed skills and capabilities"
|
|
347
|
+
if isinstance(action, MVPReadSkillAction):
|
|
348
|
+
if action.path:
|
|
349
|
+
return f"Reading skill {action.skill} ({action.path})"
|
|
350
|
+
return f"Reading skill {action.skill}"
|
|
351
|
+
if isinstance(action, MVPMaterializeSkillResourceAction):
|
|
352
|
+
return (
|
|
353
|
+
f"Materializing {action.skill}:{action.source_path} → {action.destination_path}"
|
|
354
|
+
)
|
|
355
|
+
if isinstance(action, MVPRunCapabilityAction):
|
|
356
|
+
claim = f", claim={action.active_claim_id}" if action.active_claim_id else ""
|
|
357
|
+
return (
|
|
358
|
+
f"Running capability {action.capability} "
|
|
359
|
+
f"(stage={action.stage.value}{claim})"
|
|
360
|
+
)
|
|
361
|
+
if isinstance(action, MVPAuthorAndRunCapabilityAction):
|
|
362
|
+
claim = f", claim={action.active_claim_id}" if action.active_claim_id else ""
|
|
363
|
+
return (
|
|
364
|
+
f"Authoring and running {action.path} on {action.capability} "
|
|
365
|
+
f"(stage={action.stage.value}{claim})"
|
|
366
|
+
)
|
|
367
|
+
if isinstance(action, MVPRegisterClaimAction):
|
|
368
|
+
return f"Registering {action.claim_id} ({action.kind.value})"
|
|
369
|
+
if isinstance(action, MVPRegisterEvidenceContractAction):
|
|
370
|
+
return f"Registering evidence contract for {action.claim_id}"
|
|
371
|
+
if isinstance(action, MVPLinkClaimEvidenceAction):
|
|
372
|
+
return f"Linking evidence {action.path} to {action.claim_id}"
|
|
373
|
+
if isinstance(action, MVPCloseClaimAction):
|
|
374
|
+
return f"Closing {action.claim_id} as {action.status.value}"
|
|
375
|
+
if isinstance(action, MVPFinishAction):
|
|
376
|
+
return "Finishing the campaign"
|
|
377
|
+
if action.action.value == "list_claims":
|
|
378
|
+
return "Listing claims"
|
|
379
|
+
return f"Performing {action.action.value}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def action_details(action: MVPAgentAction) -> dict[str, Any]:
|
|
383
|
+
details: dict[str, Any] = {"action_name": action.action.value}
|
|
384
|
+
if isinstance(
|
|
385
|
+
action,
|
|
386
|
+
(MVPRunCapabilityAction, MVPAuthorAndRunCapabilityAction),
|
|
387
|
+
):
|
|
388
|
+
details["capability"] = action.capability
|
|
389
|
+
details["stage"] = action.stage.value
|
|
390
|
+
details["active_claim_id"] = action.active_claim_id
|
|
391
|
+
details["argv"] = action.argv
|
|
392
|
+
if isinstance(action, MVPAuthorAndRunCapabilityAction):
|
|
393
|
+
details["path"] = action.path
|
|
394
|
+
elif isinstance(action, MVPRunPythonAction):
|
|
395
|
+
details["argv"] = action.argv
|
|
396
|
+
details["active_claim_id"] = action.active_claim_id
|
|
397
|
+
elif isinstance(
|
|
398
|
+
action,
|
|
399
|
+
(MVPWriteFileAction, MVPReadFileAction, MVPListFilesAction, MVPReadSkillAction),
|
|
400
|
+
):
|
|
401
|
+
details["path"] = action.path
|
|
402
|
+
elif isinstance(action, MVPMaterializeSkillResourceAction):
|
|
403
|
+
details["path"] = action.destination_path
|
|
404
|
+
elif isinstance(
|
|
405
|
+
action,
|
|
406
|
+
(
|
|
407
|
+
MVPRegisterClaimAction,
|
|
408
|
+
MVPRegisterEvidenceContractAction,
|
|
409
|
+
MVPLinkClaimEvidenceAction,
|
|
410
|
+
MVPCloseClaimAction,
|
|
411
|
+
),
|
|
412
|
+
):
|
|
413
|
+
details["active_claim_id"] = action.claim_id
|
|
414
|
+
if isinstance(action, MVPLinkClaimEvidenceAction):
|
|
415
|
+
details["path"] = action.path
|
|
416
|
+
return details
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def read_new_transcript_records(
|
|
420
|
+
path: Path,
|
|
421
|
+
cursor: TranscriptCursor,
|
|
422
|
+
) -> tuple[list[dict[str, Any]], TranscriptCursor, tuple[str, ...]]:
|
|
423
|
+
"""Read newly completed JSONL records without consuming a partial last line."""
|
|
424
|
+
|
|
425
|
+
if not path.exists() or not path.is_file():
|
|
426
|
+
return [], TranscriptCursor(), ()
|
|
427
|
+
stat = path.stat()
|
|
428
|
+
inode = getattr(stat, "st_ino", None)
|
|
429
|
+
reset = False
|
|
430
|
+
if cursor.inode is not None and inode != cursor.inode:
|
|
431
|
+
reset = True
|
|
432
|
+
if stat.st_size < cursor.offset:
|
|
433
|
+
reset = True
|
|
434
|
+
start = 0 if reset else cursor.offset
|
|
435
|
+
with path.open("rb") as stream:
|
|
436
|
+
stream.seek(start)
|
|
437
|
+
chunk = stream.read()
|
|
438
|
+
newline_at = chunk.rfind(b"\n")
|
|
439
|
+
if newline_at < 0:
|
|
440
|
+
return (
|
|
441
|
+
[],
|
|
442
|
+
TranscriptCursor(
|
|
443
|
+
inode=inode,
|
|
444
|
+
offset=start,
|
|
445
|
+
size=stat.st_size,
|
|
446
|
+
mtime_ns=stat.st_mtime_ns,
|
|
447
|
+
),
|
|
448
|
+
(),
|
|
449
|
+
)
|
|
450
|
+
complete = chunk[: newline_at + 1]
|
|
451
|
+
records: list[dict[str, Any]] = []
|
|
452
|
+
warnings: list[str] = []
|
|
453
|
+
for raw_line in complete.split(b"\n"):
|
|
454
|
+
if not raw_line.strip():
|
|
455
|
+
continue
|
|
456
|
+
try:
|
|
457
|
+
payload = json.loads(raw_line.decode("utf-8"))
|
|
458
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
459
|
+
warnings.append(f"skipped malformed transcript line: {error}")
|
|
460
|
+
continue
|
|
461
|
+
if isinstance(payload, dict):
|
|
462
|
+
records.append(payload)
|
|
463
|
+
else:
|
|
464
|
+
warnings.append("skipped non-object transcript line")
|
|
465
|
+
return (
|
|
466
|
+
records,
|
|
467
|
+
TranscriptCursor(
|
|
468
|
+
inode=inode,
|
|
469
|
+
offset=start + len(complete),
|
|
470
|
+
size=stat.st_size,
|
|
471
|
+
mtime_ns=stat.st_mtime_ns,
|
|
472
|
+
),
|
|
473
|
+
tuple(warnings),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def load_json_object(path: Path) -> dict[str, Any] | None:
|
|
478
|
+
if not path.exists() or not path.is_file():
|
|
479
|
+
return None
|
|
480
|
+
try:
|
|
481
|
+
payload = json.loads(path.read_text())
|
|
482
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
483
|
+
return None
|
|
484
|
+
return payload if isinstance(payload, dict) else None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def directory_bytes(root: Path) -> int:
|
|
488
|
+
if not root.is_dir():
|
|
489
|
+
return 0
|
|
490
|
+
resolved = root.resolve()
|
|
491
|
+
total = 0
|
|
492
|
+
for dirpath, dirnames, filenames in os.walk(resolved, followlinks=False):
|
|
493
|
+
current = Path(dirpath)
|
|
494
|
+
try:
|
|
495
|
+
if not current.resolve().is_relative_to(resolved):
|
|
496
|
+
dirnames.clear()
|
|
497
|
+
continue
|
|
498
|
+
except OSError:
|
|
499
|
+
dirnames.clear()
|
|
500
|
+
continue
|
|
501
|
+
for name in filenames:
|
|
502
|
+
path = current / name
|
|
503
|
+
if path.is_symlink():
|
|
504
|
+
continue
|
|
505
|
+
try:
|
|
506
|
+
total += path.stat().st_size
|
|
507
|
+
except OSError:
|
|
508
|
+
continue
|
|
509
|
+
return total
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def contained_path(root: Path, relative: str | Path) -> Path | None:
|
|
513
|
+
resolved_root = root.resolve()
|
|
514
|
+
candidate = (resolved_root / relative).resolve()
|
|
515
|
+
if not candidate.is_relative_to(resolved_root):
|
|
516
|
+
return None
|
|
517
|
+
return candidate
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def list_contained_artifacts(
|
|
521
|
+
run_directory: Path,
|
|
522
|
+
*,
|
|
523
|
+
max_entries: int = 200,
|
|
524
|
+
) -> tuple[ContainedArtifact, ...]:
|
|
525
|
+
root = Path(run_directory).resolve()
|
|
526
|
+
if not root.is_dir():
|
|
527
|
+
return ()
|
|
528
|
+
entries: list[ContainedArtifact] = []
|
|
529
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
530
|
+
current = Path(dirpath)
|
|
531
|
+
try:
|
|
532
|
+
if not current.resolve().is_relative_to(root):
|
|
533
|
+
dirnames.clear()
|
|
534
|
+
continue
|
|
535
|
+
except OSError:
|
|
536
|
+
dirnames.clear()
|
|
537
|
+
continue
|
|
538
|
+
dirnames.sort()
|
|
539
|
+
for name in sorted(filenames):
|
|
540
|
+
path = current / name
|
|
541
|
+
if path.is_symlink():
|
|
542
|
+
kind = "symlink"
|
|
543
|
+
size = 0
|
|
544
|
+
elif path.is_file():
|
|
545
|
+
kind = "file"
|
|
546
|
+
try:
|
|
547
|
+
size = path.stat().st_size
|
|
548
|
+
except OSError:
|
|
549
|
+
size = 0
|
|
550
|
+
else:
|
|
551
|
+
continue
|
|
552
|
+
relative = path.relative_to(root).as_posix()
|
|
553
|
+
entries.append(
|
|
554
|
+
ContainedArtifact(relative_path=relative, bytes=size, kind=kind)
|
|
555
|
+
)
|
|
556
|
+
if len(entries) >= max_entries:
|
|
557
|
+
return tuple(entries)
|
|
558
|
+
return tuple(entries)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def discover_recent_runs(
|
|
562
|
+
roots: Iterable[str | Path] | None = None,
|
|
563
|
+
*,
|
|
564
|
+
limit: int = 20,
|
|
565
|
+
) -> tuple[RecentRun, ...]:
|
|
566
|
+
search_roots = [Path(item).expanduser() for item in roots] if roots else _default_run_roots()
|
|
567
|
+
found: dict[str, RecentRun] = {}
|
|
568
|
+
for search_root in search_roots:
|
|
569
|
+
if not search_root.is_dir():
|
|
570
|
+
continue
|
|
571
|
+
for manifest in _candidate_manifests(search_root):
|
|
572
|
+
run_dir = manifest.parent
|
|
573
|
+
key = str(run_dir)
|
|
574
|
+
if key in found:
|
|
575
|
+
continue
|
|
576
|
+
try:
|
|
577
|
+
snapshot = load_run_snapshot(run_dir)
|
|
578
|
+
except OSError:
|
|
579
|
+
continue
|
|
580
|
+
found[key] = RecentRun(
|
|
581
|
+
run_directory=snapshot.identity.run_directory,
|
|
582
|
+
campaign_id=snapshot.identity.campaign_id,
|
|
583
|
+
phase=snapshot.phase,
|
|
584
|
+
hypothesis=snapshot.identity.hypothesis,
|
|
585
|
+
modified_at=_latest_mtime(run_dir),
|
|
586
|
+
)
|
|
587
|
+
ordered = sorted(
|
|
588
|
+
found.values(),
|
|
589
|
+
key=lambda item: item.modified_at or datetime.fromtimestamp(0, UTC),
|
|
590
|
+
reverse=True,
|
|
591
|
+
)
|
|
592
|
+
return tuple(ordered[:limit])
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _default_run_roots() -> list[Path]:
|
|
596
|
+
cwd = Path.cwd()
|
|
597
|
+
return [cwd, cwd / "artifacts"]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _candidate_manifests(root: Path) -> list[Path]:
|
|
601
|
+
if (root / "mvp_manifest.json").is_file():
|
|
602
|
+
return [root / "mvp_manifest.json"]
|
|
603
|
+
matches: list[Path] = []
|
|
604
|
+
try:
|
|
605
|
+
children = list(root.iterdir())
|
|
606
|
+
except OSError:
|
|
607
|
+
return []
|
|
608
|
+
for child in children:
|
|
609
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
610
|
+
continue
|
|
611
|
+
manifest = child / "mvp_manifest.json"
|
|
612
|
+
if manifest.is_file():
|
|
613
|
+
matches.append(manifest)
|
|
614
|
+
continue
|
|
615
|
+
try:
|
|
616
|
+
grandchildren = list(child.iterdir())
|
|
617
|
+
except OSError:
|
|
618
|
+
continue
|
|
619
|
+
for grandchild in grandchildren:
|
|
620
|
+
nested = grandchild / "mvp_manifest.json"
|
|
621
|
+
if grandchild.is_dir() and nested.is_file():
|
|
622
|
+
matches.append(nested)
|
|
623
|
+
return matches
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _latest_mtime(run_directory: Path) -> datetime | None:
|
|
627
|
+
newest: int | None = None
|
|
628
|
+
for name in ("mvp_report.json", "transcript.jsonl", "mvp_manifest.json"):
|
|
629
|
+
path = run_directory / name
|
|
630
|
+
if not path.exists():
|
|
631
|
+
continue
|
|
632
|
+
try:
|
|
633
|
+
stamp = path.stat().st_mtime_ns
|
|
634
|
+
except OSError:
|
|
635
|
+
continue
|
|
636
|
+
newest = stamp if newest is None else max(newest, stamp)
|
|
637
|
+
if newest is None:
|
|
638
|
+
return None
|
|
639
|
+
return datetime.fromtimestamp(newest / 1_000_000_000, UTC)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _short_argv(argv: tuple[str, ...] | list[str], *, limit: int = 72) -> str:
|
|
643
|
+
text = " ".join(str(item) for item in argv)
|
|
644
|
+
if len(text) > limit:
|
|
645
|
+
return text[: limit - 3] + "..."
|
|
646
|
+
return text or "(no arguments)"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _existing_path(path: Path) -> str | None:
|
|
650
|
+
return str(path) if path.exists() else None
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _as_int(value: Any) -> int | None:
|
|
654
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
655
|
+
return None
|
|
656
|
+
return int(value)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _as_float(value: Any) -> float | None:
|
|
660
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
661
|
+
return None
|
|
662
|
+
return float(value)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
666
|
+
if isinstance(value, datetime):
|
|
667
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
668
|
+
if not isinstance(value, str) or not value:
|
|
669
|
+
return None
|
|
670
|
+
try:
|
|
671
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
672
|
+
except ValueError:
|
|
673
|
+
return None
|
|
674
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _iteration_of(record: dict[str, Any]) -> int | None:
|
|
678
|
+
value = record.get("iteration")
|
|
679
|
+
return value if isinstance(value, int) and value >= 1 else None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _action_from_assistant(record: dict[str, Any]) -> MVPAgentAction | None:
|
|
683
|
+
content = record.get("content")
|
|
684
|
+
if not isinstance(content, str) or not content.strip():
|
|
685
|
+
return None
|
|
686
|
+
try:
|
|
687
|
+
return parse_mvp_action(content)
|
|
688
|
+
except ValueError:
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _tool_payload(record: dict[str, Any]) -> dict[str, Any] | None:
|
|
693
|
+
content = record.get("content")
|
|
694
|
+
if not isinstance(content, str):
|
|
695
|
+
return None
|
|
696
|
+
try:
|
|
697
|
+
payload = json.loads(content)
|
|
698
|
+
except json.JSONDecodeError:
|
|
699
|
+
return None
|
|
700
|
+
if not isinstance(payload, dict):
|
|
701
|
+
return None
|
|
702
|
+
tool = payload.get("tool_result")
|
|
703
|
+
return tool if isinstance(tool, dict) else None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class MVPRunMonitor:
|
|
707
|
+
"""Incrementally project a run directory into an operator-facing snapshot."""
|
|
708
|
+
|
|
709
|
+
def __init__(self, run_directory: str | Path) -> None:
|
|
710
|
+
self.root = Path(run_directory).expanduser().resolve()
|
|
711
|
+
self._cursor = TranscriptCursor()
|
|
712
|
+
self._state = _TranscriptState()
|
|
713
|
+
self._sidecar_cache: dict[str, tuple[int | None, dict[str, Any] | None]] = {}
|
|
714
|
+
|
|
715
|
+
def snapshot(self, *, now: datetime | None = None) -> MVPRunSnapshot:
|
|
716
|
+
observed_at = now or utc_now()
|
|
717
|
+
warnings: list[str] = []
|
|
718
|
+
if not self.root.exists():
|
|
719
|
+
raise FileNotFoundError(f"run directory does not exist: {self.root}")
|
|
720
|
+
if not self.root.is_dir():
|
|
721
|
+
raise NotADirectoryError(f"run path is not a directory: {self.root}")
|
|
722
|
+
warnings.extend(self._ingest_transcript(observed_at=observed_at))
|
|
723
|
+
manifest = self._read_sidecar("mvp_manifest.json")
|
|
724
|
+
report = self._read_sidecar("mvp_report.json")
|
|
725
|
+
ledger = self._read_sidecar("hypothesis_ledger.json")
|
|
726
|
+
launch = self._read_sidecar(f"{OPERATOR_INPUT_DIR}/launch.json")
|
|
727
|
+
if (self.root / "mvp_manifest.json").exists() and manifest is None:
|
|
728
|
+
warnings.append("mvp_manifest.json exists but could not be parsed")
|
|
729
|
+
if (self.root / "mvp_report.json").exists() and report is None:
|
|
730
|
+
warnings.append("mvp_report.json exists but could not be parsed")
|
|
731
|
+
if (self.root / "hypothesis_ledger.json").exists() and ledger is None:
|
|
732
|
+
warnings.append("hypothesis_ledger.json exists but could not be parsed")
|
|
733
|
+
identity = self._identity(manifest, report, ledger, launch)
|
|
734
|
+
phase = self._phase(report)
|
|
735
|
+
current = None if report is not None else self._current_action()
|
|
736
|
+
claims = self._claims(ledger, report, current)
|
|
737
|
+
heartbeat = self._heartbeat(observed_at)
|
|
738
|
+
terminal = self._terminal_report(report)
|
|
739
|
+
configured = None
|
|
740
|
+
if isinstance(identity.config.get("max_wall_seconds"), (int, float)):
|
|
741
|
+
configured = float(identity.config["max_wall_seconds"])
|
|
742
|
+
elapsed, elapsed_is_estimate = self._elapsed(
|
|
743
|
+
report=report,
|
|
744
|
+
configured=configured,
|
|
745
|
+
heartbeat=heartbeat,
|
|
746
|
+
observed_at=observed_at,
|
|
747
|
+
)
|
|
748
|
+
workspace_size = 0
|
|
749
|
+
if heartbeat is not None and heartbeat.workspace_bytes is not None:
|
|
750
|
+
workspace_size = heartbeat.workspace_bytes
|
|
751
|
+
else:
|
|
752
|
+
workspace_size = directory_bytes(self.root / "workspace")
|
|
753
|
+
open_ids = tuple(claim.id for claim in claims if claim.status == "open")
|
|
754
|
+
closed_ids = tuple(claim.id for claim in claims if claim.status != "open")
|
|
755
|
+
if terminal is not None:
|
|
756
|
+
if terminal.open_claim_ids:
|
|
757
|
+
open_ids = terminal.open_claim_ids
|
|
758
|
+
if terminal.closed_claim_ids:
|
|
759
|
+
closed_ids = terminal.closed_claim_ids
|
|
760
|
+
warnings.extend(self._state.parse_warnings[-8:])
|
|
761
|
+
control = read_control(self.root)
|
|
762
|
+
pending = (
|
|
763
|
+
control.command.value
|
|
764
|
+
if control.command is not ControlCommand.NONE
|
|
765
|
+
else None
|
|
766
|
+
)
|
|
767
|
+
return MVPRunSnapshot(
|
|
768
|
+
phase=phase,
|
|
769
|
+
phase_label=phase_label(phase),
|
|
770
|
+
identity=identity,
|
|
771
|
+
configured_wall_seconds=configured,
|
|
772
|
+
elapsed_wall_seconds=elapsed,
|
|
773
|
+
elapsed_is_estimate=elapsed_is_estimate,
|
|
774
|
+
iterations=len(self._state.assistant),
|
|
775
|
+
action_counts=dict(self._state.action_counts),
|
|
776
|
+
claims=claims,
|
|
777
|
+
open_claim_ids=open_ids,
|
|
778
|
+
closed_claim_ids=closed_ids,
|
|
779
|
+
current_action=current,
|
|
780
|
+
latest_heartbeat=heartbeat,
|
|
781
|
+
recent_events=tuple(self._state.events[-DEFAULT_RECENT_EVENT_LIMIT:]),
|
|
782
|
+
report=terminal,
|
|
783
|
+
artifacts=self._artifact_paths(),
|
|
784
|
+
workspace_bytes=workspace_size,
|
|
785
|
+
last_model=self._state.last_model,
|
|
786
|
+
last_capability=self._state.last_capability,
|
|
787
|
+
token_usage=self._token_usage(),
|
|
788
|
+
pending_control=pending,
|
|
789
|
+
warnings=tuple(dict.fromkeys(warnings)),
|
|
790
|
+
transcript_cursor=self._cursor,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def _ingest_transcript(self, *, observed_at: datetime) -> list[str]:
|
|
794
|
+
transcript = self.root / "transcript.jsonl"
|
|
795
|
+
previous = self._cursor
|
|
796
|
+
records, cursor, warnings = read_new_transcript_records(transcript, previous)
|
|
797
|
+
if previous.inode is not None and (
|
|
798
|
+
cursor.inode != previous.inode or cursor.offset < previous.offset
|
|
799
|
+
):
|
|
800
|
+
self._state.reset()
|
|
801
|
+
self._cursor = cursor
|
|
802
|
+
recorded_fallback = observed_at
|
|
803
|
+
try:
|
|
804
|
+
recorded_fallback = datetime.fromtimestamp(transcript.stat().st_mtime, UTC)
|
|
805
|
+
except OSError:
|
|
806
|
+
recorded_fallback = observed_at
|
|
807
|
+
for record in records:
|
|
808
|
+
self._ingest_record(record, observed_at=recorded_fallback)
|
|
809
|
+
return list(warnings)
|
|
810
|
+
|
|
811
|
+
def _ingest_record(self, record: dict[str, Any], *, observed_at: datetime) -> None:
|
|
812
|
+
kind = str(record.get("kind") or "unknown")
|
|
813
|
+
iteration = _iteration_of(record)
|
|
814
|
+
if kind == "assistant":
|
|
815
|
+
if iteration is None:
|
|
816
|
+
return
|
|
817
|
+
self._state.assistant[iteration] = record
|
|
818
|
+
if isinstance(record.get("model"), str):
|
|
819
|
+
self._state.last_model = record["model"]
|
|
820
|
+
if isinstance(record.get("route"), str):
|
|
821
|
+
self._state.last_route = record["route"]
|
|
822
|
+
action = _action_from_assistant(record)
|
|
823
|
+
action_name = action.action.value if action is not None else None
|
|
824
|
+
self._accumulate_usage(record)
|
|
825
|
+
if action is not None:
|
|
826
|
+
self._state.action_counts[action.action.value] += 1
|
|
827
|
+
details = action_details(action)
|
|
828
|
+
if details.get("capability"):
|
|
829
|
+
self._state.last_capability = str(details["capability"])
|
|
830
|
+
summary = humanize_action(action)
|
|
831
|
+
else:
|
|
832
|
+
summary = "Model turn recorded; no typed action parsed"
|
|
833
|
+
self._state.parse_warnings.append(
|
|
834
|
+
f"assistant iteration {iteration} has no typed action"
|
|
835
|
+
)
|
|
836
|
+
self._push_event(
|
|
837
|
+
HumanizedEvent(
|
|
838
|
+
kind="assistant",
|
|
839
|
+
iteration=iteration,
|
|
840
|
+
summary=summary,
|
|
841
|
+
action_name=action_name,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
return
|
|
845
|
+
if kind == "tool":
|
|
846
|
+
if iteration is not None:
|
|
847
|
+
self._state.tools[iteration] = record
|
|
848
|
+
payload = _tool_payload(record)
|
|
849
|
+
action = None
|
|
850
|
+
if iteration in self._state.assistant:
|
|
851
|
+
action = _action_from_assistant(self._state.assistant[iteration])
|
|
852
|
+
if payload is None:
|
|
853
|
+
summary = f"Tool result recorded for iteration {iteration or '?'}"
|
|
854
|
+
elif payload.get("ok") is True:
|
|
855
|
+
summary = (
|
|
856
|
+
f"Completed {humanize_action(action)}"
|
|
857
|
+
if action is not None
|
|
858
|
+
else f"Completed action at iteration {iteration}"
|
|
859
|
+
)
|
|
860
|
+
else:
|
|
861
|
+
error = str(payload.get("error") or "unknown error")
|
|
862
|
+
summary = f"Action failed: {error}"
|
|
863
|
+
self._push_event(
|
|
864
|
+
HumanizedEvent(
|
|
865
|
+
kind="tool",
|
|
866
|
+
iteration=iteration,
|
|
867
|
+
summary=summary,
|
|
868
|
+
action_name=action.action.value if action is not None else None,
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
return
|
|
872
|
+
if kind == "tool_heartbeat":
|
|
873
|
+
self._state.last_heartbeat = record
|
|
874
|
+
recorded_at = _parse_datetime(record.get("recorded_at"))
|
|
875
|
+
self._state.last_heartbeat_observed_at = recorded_at or observed_at
|
|
876
|
+
elapsed = _as_float(record.get("elapsed_wall_seconds"))
|
|
877
|
+
workspace = _as_int(record.get("workspace_bytes"))
|
|
878
|
+
parts = ["Command heartbeat"]
|
|
879
|
+
if elapsed is not None:
|
|
880
|
+
parts.append(f"elapsed {format_duration(elapsed)}")
|
|
881
|
+
if workspace is not None:
|
|
882
|
+
parts.append(f"workspace {format_bytes(workspace)}")
|
|
883
|
+
self._push_event(
|
|
884
|
+
HumanizedEvent(
|
|
885
|
+
kind="tool_heartbeat",
|
|
886
|
+
iteration=iteration,
|
|
887
|
+
summary=", ".join(parts),
|
|
888
|
+
action_name=None,
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
return
|
|
892
|
+
if kind == "control":
|
|
893
|
+
event = str(record.get("event") or "control event")
|
|
894
|
+
action_name = record.get("action") if isinstance(record.get("action"), str) else None
|
|
895
|
+
if event == "campaign_cancelled":
|
|
896
|
+
summary = "Campaign cancelled"
|
|
897
|
+
elif event == "campaign_paused":
|
|
898
|
+
summary = "Paused at the next action boundary"
|
|
899
|
+
elif event == "interrupted_action_recovered":
|
|
900
|
+
summary = (
|
|
901
|
+
f"Recovered interrupted {action_name}"
|
|
902
|
+
if action_name
|
|
903
|
+
else "Recovered interrupted action"
|
|
904
|
+
)
|
|
905
|
+
elif event == "model_completion_retry":
|
|
906
|
+
attempt = record.get("attempt")
|
|
907
|
+
summary = f"Retrying model completion (attempt {attempt})"
|
|
908
|
+
else:
|
|
909
|
+
summary = event.replace("_", " ")
|
|
910
|
+
self._push_event(
|
|
911
|
+
HumanizedEvent(
|
|
912
|
+
kind="control",
|
|
913
|
+
iteration=iteration,
|
|
914
|
+
summary=summary,
|
|
915
|
+
action_name=action_name,
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def _push_event(self, event: HumanizedEvent) -> None:
|
|
920
|
+
self._state.next_event_sequence += 1
|
|
921
|
+
self._state.events.append(
|
|
922
|
+
event.model_copy(update={"sequence": self._state.next_event_sequence})
|
|
923
|
+
)
|
|
924
|
+
if len(self._state.events) > _MAX_RETAINED_EVENTS:
|
|
925
|
+
self._state.events = self._state.events[-_MAX_RETAINED_EVENTS:]
|
|
926
|
+
|
|
927
|
+
def _read_sidecar(self, relative: str) -> dict[str, Any] | None:
|
|
928
|
+
path = self.root / relative
|
|
929
|
+
if not path.is_file():
|
|
930
|
+
self._sidecar_cache.pop(relative, None)
|
|
931
|
+
return None
|
|
932
|
+
try:
|
|
933
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
934
|
+
except OSError:
|
|
935
|
+
return None
|
|
936
|
+
cached = self._sidecar_cache.get(relative)
|
|
937
|
+
if cached is not None and cached[0] == mtime_ns:
|
|
938
|
+
return cached[1]
|
|
939
|
+
payload = load_json_object(path)
|
|
940
|
+
self._sidecar_cache[relative] = (mtime_ns, payload)
|
|
941
|
+
return payload
|
|
942
|
+
|
|
943
|
+
def _identity(
|
|
944
|
+
self,
|
|
945
|
+
manifest: dict[str, Any] | None,
|
|
946
|
+
report: dict[str, Any] | None,
|
|
947
|
+
ledger: dict[str, Any] | None,
|
|
948
|
+
launch: dict[str, Any] | None,
|
|
949
|
+
) -> RunIdentity:
|
|
950
|
+
hypothesis = None
|
|
951
|
+
instruction = None
|
|
952
|
+
config: dict[str, Any] = {}
|
|
953
|
+
schema_version = None
|
|
954
|
+
skill_hashes: dict[str, str] = {}
|
|
955
|
+
capability_hashes: dict[str, str] = {}
|
|
956
|
+
campaign_id = None
|
|
957
|
+
if launch and isinstance(launch.get("campaign_id"), str):
|
|
958
|
+
campaign_id = launch["campaign_id"]
|
|
959
|
+
if campaign_id is None:
|
|
960
|
+
campaign_id = self.root.name
|
|
961
|
+
source = manifest or {}
|
|
962
|
+
if source:
|
|
963
|
+
if isinstance(source.get("hypothesis"), str):
|
|
964
|
+
hypothesis = source["hypothesis"]
|
|
965
|
+
if source.get("campaign_instruction") is None or isinstance(
|
|
966
|
+
source.get("campaign_instruction"), str
|
|
967
|
+
):
|
|
968
|
+
instruction = source.get("campaign_instruction")
|
|
969
|
+
if isinstance(source.get("schema_version"), str):
|
|
970
|
+
schema_version = source["schema_version"]
|
|
971
|
+
if isinstance(source.get("config"), dict):
|
|
972
|
+
config = source["config"]
|
|
973
|
+
if isinstance(source.get("skill_hashes"), dict):
|
|
974
|
+
skill_hashes = {
|
|
975
|
+
str(key): str(value) for key, value in source["skill_hashes"].items()
|
|
976
|
+
}
|
|
977
|
+
if isinstance(source.get("capability_hashes"), dict):
|
|
978
|
+
capability_hashes = {
|
|
979
|
+
str(key): str(value)
|
|
980
|
+
for key, value in source["capability_hashes"].items()
|
|
981
|
+
}
|
|
982
|
+
if report:
|
|
983
|
+
if hypothesis is None and isinstance(report.get("hypothesis"), str):
|
|
984
|
+
hypothesis = report["hypothesis"]
|
|
985
|
+
if instruction is None and (
|
|
986
|
+
report.get("campaign_instruction") is None
|
|
987
|
+
or isinstance(report.get("campaign_instruction"), str)
|
|
988
|
+
):
|
|
989
|
+
instruction = report.get("campaign_instruction")
|
|
990
|
+
if ledger and hypothesis is None and isinstance(ledger.get("root_hypothesis"), str):
|
|
991
|
+
hypothesis = ledger["root_hypothesis"]
|
|
992
|
+
operator_hypothesis = self.root / OPERATOR_INPUT_DIR / "hypothesis.txt"
|
|
993
|
+
if hypothesis is None and operator_hypothesis.is_file():
|
|
994
|
+
try:
|
|
995
|
+
text = operator_hypothesis.read_text().strip()
|
|
996
|
+
except OSError:
|
|
997
|
+
text = ""
|
|
998
|
+
if text:
|
|
999
|
+
hypothesis = text
|
|
1000
|
+
return RunIdentity(
|
|
1001
|
+
run_directory=str(self.root),
|
|
1002
|
+
campaign_id=campaign_id,
|
|
1003
|
+
hypothesis=hypothesis,
|
|
1004
|
+
campaign_instruction=instruction,
|
|
1005
|
+
manifest_schema_version=schema_version,
|
|
1006
|
+
config=config,
|
|
1007
|
+
skill_hashes=skill_hashes,
|
|
1008
|
+
capability_hashes=capability_hashes,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
def _phase(self, report: dict[str, Any] | None) -> RunPhase:
|
|
1012
|
+
if report is not None:
|
|
1013
|
+
status = report.get("status")
|
|
1014
|
+
if status == "completed":
|
|
1015
|
+
return RunPhase.COMPLETED
|
|
1016
|
+
if status == "cancelled":
|
|
1017
|
+
return RunPhase.CANCELLED
|
|
1018
|
+
if status == "provider_failed":
|
|
1019
|
+
return RunPhase.PROVIDER_FAILED
|
|
1020
|
+
if status == "budget_exhausted":
|
|
1021
|
+
return RunPhase.BUDGET_EXHAUSTED
|
|
1022
|
+
# An unrecognized report is still a terminal document, not "running".
|
|
1023
|
+
return RunPhase.INCOMPLETE
|
|
1024
|
+
pause = read_pause_state(self.root)
|
|
1025
|
+
clock = read_clock(self.root)
|
|
1026
|
+
if pause is not None or (clock is not None and clock.state == "paused"):
|
|
1027
|
+
return RunPhase.PAUSED
|
|
1028
|
+
if self._state.assistant or self._state.tools or self._state.events:
|
|
1029
|
+
return RunPhase.INCOMPLETE
|
|
1030
|
+
transcript = self.root / "transcript.jsonl"
|
|
1031
|
+
if transcript.is_file():
|
|
1032
|
+
try:
|
|
1033
|
+
if transcript.stat().st_size > 0:
|
|
1034
|
+
return RunPhase.INCOMPLETE
|
|
1035
|
+
except OSError:
|
|
1036
|
+
return RunPhase.INCOMPLETE
|
|
1037
|
+
if (self.root / "mvp_manifest.json").is_file():
|
|
1038
|
+
return RunPhase.INITIALIZED
|
|
1039
|
+
return RunPhase.INCOMPLETE
|
|
1040
|
+
|
|
1041
|
+
def _current_action(self) -> CurrentAction | None:
|
|
1042
|
+
pending_iterations = [
|
|
1043
|
+
iteration
|
|
1044
|
+
for iteration in self._state.assistant
|
|
1045
|
+
if iteration not in self._state.tools
|
|
1046
|
+
]
|
|
1047
|
+
if not pending_iterations:
|
|
1048
|
+
return None
|
|
1049
|
+
iteration = max(pending_iterations)
|
|
1050
|
+
record = self._state.assistant[iteration]
|
|
1051
|
+
action = _action_from_assistant(record)
|
|
1052
|
+
details = action_details(action) if action is not None else {}
|
|
1053
|
+
description = (
|
|
1054
|
+
humanize_action(action)
|
|
1055
|
+
if action is not None
|
|
1056
|
+
else "Model turn recorded; waiting for a tool result"
|
|
1057
|
+
)
|
|
1058
|
+
argv = details.get("argv") or ()
|
|
1059
|
+
return CurrentAction(
|
|
1060
|
+
iteration=iteration,
|
|
1061
|
+
action_name=details.get("action_name")
|
|
1062
|
+
if action is not None
|
|
1063
|
+
else None,
|
|
1064
|
+
description=description,
|
|
1065
|
+
pending=True,
|
|
1066
|
+
capability=details.get("capability"),
|
|
1067
|
+
stage=details.get("stage"),
|
|
1068
|
+
active_claim_id=details.get("active_claim_id"),
|
|
1069
|
+
path=details.get("path"),
|
|
1070
|
+
model=record.get("model") if isinstance(record.get("model"), str) else None,
|
|
1071
|
+
route=record.get("route") if isinstance(record.get("route"), str) else None,
|
|
1072
|
+
argv=tuple(str(item) for item in argv),
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
def _claims(
|
|
1076
|
+
self,
|
|
1077
|
+
ledger: dict[str, Any] | None,
|
|
1078
|
+
report: dict[str, Any] | None,
|
|
1079
|
+
current: CurrentAction | None,
|
|
1080
|
+
) -> tuple[ClaimSummary, ...]:
|
|
1081
|
+
payload = None
|
|
1082
|
+
if ledger and isinstance(ledger.get("claims"), list):
|
|
1083
|
+
payload = ledger["claims"]
|
|
1084
|
+
elif report and isinstance(report.get("claim_ledger"), dict):
|
|
1085
|
+
claims = report["claim_ledger"].get("claims")
|
|
1086
|
+
if isinstance(claims, list):
|
|
1087
|
+
payload = claims
|
|
1088
|
+
if not payload:
|
|
1089
|
+
return ()
|
|
1090
|
+
active_id = current.active_claim_id if current is not None else None
|
|
1091
|
+
summaries: list[ClaimSummary] = []
|
|
1092
|
+
for item in payload:
|
|
1093
|
+
if not isinstance(item, dict):
|
|
1094
|
+
continue
|
|
1095
|
+
claim_id = item.get("id")
|
|
1096
|
+
if not isinstance(claim_id, str) or not claim_id:
|
|
1097
|
+
continue
|
|
1098
|
+
evidence = item.get("evidence") or []
|
|
1099
|
+
contracts = item.get("evidence_contracts") or []
|
|
1100
|
+
summaries.append(
|
|
1101
|
+
ClaimSummary(
|
|
1102
|
+
id=claim_id,
|
|
1103
|
+
status=str(item.get("status") or "open"),
|
|
1104
|
+
kind=str(item["kind"]) if isinstance(item.get("kind"), str) else None,
|
|
1105
|
+
relation=(
|
|
1106
|
+
str(item["relation"])
|
|
1107
|
+
if isinstance(item.get("relation"), str)
|
|
1108
|
+
else None
|
|
1109
|
+
),
|
|
1110
|
+
parent_id=(
|
|
1111
|
+
str(item["parent_id"])
|
|
1112
|
+
if isinstance(item.get("parent_id"), str)
|
|
1113
|
+
else None
|
|
1114
|
+
),
|
|
1115
|
+
statement=str(item.get("statement") or ""),
|
|
1116
|
+
evidence_count=len(evidence) if isinstance(evidence, list) else 0,
|
|
1117
|
+
contract_count=len(contracts) if isinstance(contracts, list) else 0,
|
|
1118
|
+
closed_reason=(
|
|
1119
|
+
str(item["closed_reason"])
|
|
1120
|
+
if isinstance(item.get("closed_reason"), str)
|
|
1121
|
+
else None
|
|
1122
|
+
),
|
|
1123
|
+
active=claim_id == active_id,
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
return tuple(summaries)
|
|
1127
|
+
|
|
1128
|
+
def _heartbeat(self, observed_at: datetime) -> HeartbeatObservation | None:
|
|
1129
|
+
record = self._state.last_heartbeat
|
|
1130
|
+
if not record:
|
|
1131
|
+
return None
|
|
1132
|
+
iteration = _iteration_of(record)
|
|
1133
|
+
if iteration is None:
|
|
1134
|
+
return None
|
|
1135
|
+
seen_at = self._state.last_heartbeat_observed_at
|
|
1136
|
+
age = None
|
|
1137
|
+
if seen_at is not None:
|
|
1138
|
+
age = max(0.0, (observed_at - seen_at).total_seconds())
|
|
1139
|
+
return HeartbeatObservation(
|
|
1140
|
+
iteration=iteration,
|
|
1141
|
+
elapsed_wall_seconds=_as_float(record.get("elapsed_wall_seconds")),
|
|
1142
|
+
stdout_bytes=_as_int(record.get("stdout_bytes")),
|
|
1143
|
+
stderr_bytes=_as_int(record.get("stderr_bytes")),
|
|
1144
|
+
workspace_bytes=_as_int(record.get("workspace_bytes")),
|
|
1145
|
+
observed_at=seen_at,
|
|
1146
|
+
age_seconds=age,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def _terminal_report(
|
|
1150
|
+
self, report: dict[str, Any] | None
|
|
1151
|
+
) -> TerminalReportSummary | None:
|
|
1152
|
+
if not report:
|
|
1153
|
+
return None
|
|
1154
|
+
status = report.get("status")
|
|
1155
|
+
if not isinstance(status, str):
|
|
1156
|
+
return None
|
|
1157
|
+
artifacts = report.get("workspace_artifacts")
|
|
1158
|
+
artifact_count = len(artifacts) if isinstance(artifacts, dict) else 0
|
|
1159
|
+
open_ids = tuple(
|
|
1160
|
+
str(item) for item in report.get("open_claim_ids") or [] if item
|
|
1161
|
+
)
|
|
1162
|
+
closed_ids = tuple(
|
|
1163
|
+
str(item) for item in report.get("closed_claim_ids") or [] if item
|
|
1164
|
+
)
|
|
1165
|
+
notes = tuple(
|
|
1166
|
+
str(item) for item in report.get("finish_claim_notes") or [] if item
|
|
1167
|
+
)
|
|
1168
|
+
return TerminalReportSummary(
|
|
1169
|
+
status=status,
|
|
1170
|
+
final_answer=str(report.get("final_answer") or ""),
|
|
1171
|
+
iterations=max(0, _as_int(report.get("iterations")) or 0),
|
|
1172
|
+
elapsed_wall_seconds=max(0.0, _as_float(report.get("elapsed_wall_seconds")) or 0.0),
|
|
1173
|
+
started_at=_parse_datetime(report.get("started_at")),
|
|
1174
|
+
finished_at=_parse_datetime(report.get("finished_at")),
|
|
1175
|
+
open_claim_ids=open_ids,
|
|
1176
|
+
closed_claim_ids=closed_ids,
|
|
1177
|
+
finish_claim_notes=notes,
|
|
1178
|
+
workspace_artifact_count=artifact_count,
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
def _elapsed(
|
|
1182
|
+
self,
|
|
1183
|
+
*,
|
|
1184
|
+
report: dict[str, Any] | None,
|
|
1185
|
+
configured: float | None,
|
|
1186
|
+
heartbeat: HeartbeatObservation | None,
|
|
1187
|
+
observed_at: datetime,
|
|
1188
|
+
) -> tuple[float | None, bool]:
|
|
1189
|
+
clock = read_clock(self.root)
|
|
1190
|
+
if clock is not None:
|
|
1191
|
+
return elapsed_from_clock(clock, now=observed_at), False
|
|
1192
|
+
if report is not None:
|
|
1193
|
+
value = _as_float(report.get("elapsed_wall_seconds"))
|
|
1194
|
+
if value is not None:
|
|
1195
|
+
return max(0.0, value), False
|
|
1196
|
+
started = None
|
|
1197
|
+
if report is not None:
|
|
1198
|
+
started = _parse_datetime(report.get("started_at"))
|
|
1199
|
+
if started is None:
|
|
1200
|
+
started = _latest_mtime(self.root)
|
|
1201
|
+
if started is None:
|
|
1202
|
+
return None, False
|
|
1203
|
+
return max(0.0, (observed_at - started).total_seconds()), True
|
|
1204
|
+
elapsed = max(0.0, (observed_at - started).total_seconds())
|
|
1205
|
+
if configured is not None:
|
|
1206
|
+
elapsed = min(elapsed, configured)
|
|
1207
|
+
if heartbeat is not None and heartbeat.elapsed_wall_seconds is not None:
|
|
1208
|
+
# Heartbeat is command-local; keep directory estimate for campaign clock.
|
|
1209
|
+
return elapsed, True
|
|
1210
|
+
return elapsed, True
|
|
1211
|
+
|
|
1212
|
+
def _accumulate_usage(self, record: dict[str, Any]) -> None:
|
|
1213
|
+
usage = parse_usage_payload(record.get("usage"))
|
|
1214
|
+
has_usage = any(
|
|
1215
|
+
usage[key] for key in ("prompt_tokens", "completion_tokens", "total_tokens")
|
|
1216
|
+
)
|
|
1217
|
+
if not has_usage:
|
|
1218
|
+
self._state.missing_usage_turns += 1
|
|
1219
|
+
return
|
|
1220
|
+
self._state.usage_turns += 1
|
|
1221
|
+
self._state.prompt_tokens += usage["prompt_tokens"]
|
|
1222
|
+
self._state.completion_tokens += usage["completion_tokens"]
|
|
1223
|
+
self._state.total_tokens += usage["total_tokens"]
|
|
1224
|
+
self._state.cached_tokens += usage["cached_tokens"]
|
|
1225
|
+
self._state.reasoning_tokens += usage["reasoning_tokens"]
|
|
1226
|
+
model = record.get("model") if isinstance(record.get("model"), str) else "unknown"
|
|
1227
|
+
bucket = self._state.model_usage.setdefault(
|
|
1228
|
+
model,
|
|
1229
|
+
{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "turns": 0},
|
|
1230
|
+
)
|
|
1231
|
+
bucket["prompt_tokens"] += usage["prompt_tokens"]
|
|
1232
|
+
bucket["completion_tokens"] += usage["completion_tokens"]
|
|
1233
|
+
bucket["total_tokens"] += usage["total_tokens"]
|
|
1234
|
+
bucket["turns"] += 1
|
|
1235
|
+
|
|
1236
|
+
def _token_usage(self) -> TokenUsageSummary:
|
|
1237
|
+
by_model = tuple(
|
|
1238
|
+
ModelTokenUsage(
|
|
1239
|
+
model=name,
|
|
1240
|
+
prompt_tokens=values["prompt_tokens"],
|
|
1241
|
+
completion_tokens=values["completion_tokens"],
|
|
1242
|
+
total_tokens=values["total_tokens"],
|
|
1243
|
+
turns=values["turns"],
|
|
1244
|
+
)
|
|
1245
|
+
for name, values in sorted(self._state.model_usage.items())
|
|
1246
|
+
)
|
|
1247
|
+
return TokenUsageSummary(
|
|
1248
|
+
prompt_tokens=self._state.prompt_tokens,
|
|
1249
|
+
completion_tokens=self._state.completion_tokens,
|
|
1250
|
+
total_tokens=self._state.total_tokens,
|
|
1251
|
+
cached_tokens=self._state.cached_tokens,
|
|
1252
|
+
reasoning_tokens=self._state.reasoning_tokens,
|
|
1253
|
+
turns=self._state.usage_turns,
|
|
1254
|
+
turns_missing_usage=self._state.missing_usage_turns,
|
|
1255
|
+
by_model=by_model,
|
|
1256
|
+
label=format_token_counts(
|
|
1257
|
+
prompt_tokens=self._state.prompt_tokens,
|
|
1258
|
+
completion_tokens=self._state.completion_tokens,
|
|
1259
|
+
total_tokens=self._state.total_tokens,
|
|
1260
|
+
turns=self._state.usage_turns,
|
|
1261
|
+
missing_turns=self._state.missing_usage_turns,
|
|
1262
|
+
cached_tokens=self._state.cached_tokens,
|
|
1263
|
+
),
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
def _artifact_paths(self) -> ArtifactPaths:
|
|
1267
|
+
return ArtifactPaths(
|
|
1268
|
+
run_directory=str(self.root),
|
|
1269
|
+
manifest=_existing_path(self.root / "mvp_manifest.json"),
|
|
1270
|
+
transcript=_existing_path(self.root / "transcript.jsonl"),
|
|
1271
|
+
report=_existing_path(self.root / "mvp_report.json"),
|
|
1272
|
+
claim_ledger=_existing_path(self.root / "hypothesis_ledger.json"),
|
|
1273
|
+
claim_summary=_existing_path(self.root / "claim_summary.md"),
|
|
1274
|
+
artifact_provenance=_existing_path(self.root / "artifact_provenance.json"),
|
|
1275
|
+
workspace=_existing_path(self.root / "workspace"),
|
|
1276
|
+
controller_log=_existing_path(self.root / "controller.log"),
|
|
1277
|
+
operator_input=_existing_path(self.root / OPERATOR_INPUT_DIR),
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def load_run_snapshot(
|
|
1282
|
+
run_directory: str | Path,
|
|
1283
|
+
*,
|
|
1284
|
+
now: datetime | None = None,
|
|
1285
|
+
) -> MVPRunSnapshot:
|
|
1286
|
+
return MVPRunMonitor(run_directory).snapshot(now=now)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def format_human_status(snapshot: MVPRunSnapshot) -> str:
|
|
1290
|
+
identity = snapshot.identity
|
|
1291
|
+
campaign = identity.campaign_id or Path(identity.run_directory).name
|
|
1292
|
+
lines = [
|
|
1293
|
+
f"Campaign: {campaign} {snapshot.phase_label}",
|
|
1294
|
+
f"directory: {identity.run_directory}",
|
|
1295
|
+
]
|
|
1296
|
+
elapsed = format_duration(snapshot.elapsed_wall_seconds)
|
|
1297
|
+
budget = format_duration(snapshot.configured_wall_seconds)
|
|
1298
|
+
estimate = " ~" if snapshot.elapsed_is_estimate else " "
|
|
1299
|
+
heartbeat = "heartbeat --"
|
|
1300
|
+
if snapshot.latest_heartbeat is not None:
|
|
1301
|
+
heartbeat = f"heartbeat {format_age(snapshot.latest_heartbeat.age_seconds)}"
|
|
1302
|
+
lines.append(f"elapsed{estimate}{elapsed} / {budget} {heartbeat}")
|
|
1303
|
+
model = snapshot.last_model
|
|
1304
|
+
if snapshot.current_action is not None and snapshot.current_action.model:
|
|
1305
|
+
model = snapshot.current_action.model
|
|
1306
|
+
capability = snapshot.last_capability
|
|
1307
|
+
if snapshot.current_action and snapshot.current_action.capability:
|
|
1308
|
+
capability = snapshot.current_action.capability
|
|
1309
|
+
lines.append(
|
|
1310
|
+
f"model: {model or '-'} capability: {capability or '-'}"
|
|
1311
|
+
)
|
|
1312
|
+
lines.append(f"iterations: {snapshot.iterations}")
|
|
1313
|
+
lines.append(snapshot.token_usage.label)
|
|
1314
|
+
if snapshot.pending_control:
|
|
1315
|
+
lines.append(f"pending control: {snapshot.pending_control} at next action boundary")
|
|
1316
|
+
lines.append("")
|
|
1317
|
+
lines.append("Root hypothesis")
|
|
1318
|
+
hypothesis = (identity.hypothesis or "(not yet recorded)").strip()
|
|
1319
|
+
for raw in hypothesis.splitlines() or ["(not yet recorded)"]:
|
|
1320
|
+
lines.append(f" {raw}")
|
|
1321
|
+
if identity.campaign_instruction:
|
|
1322
|
+
lines.append("")
|
|
1323
|
+
lines.append("Operational instruction")
|
|
1324
|
+
for raw in identity.campaign_instruction.strip().splitlines():
|
|
1325
|
+
lines.append(f" {raw}")
|
|
1326
|
+
lines.append("")
|
|
1327
|
+
lines.append("Claims")
|
|
1328
|
+
if not snapshot.claims:
|
|
1329
|
+
lines.append(" (no claims recorded)")
|
|
1330
|
+
for claim in snapshot.claims:
|
|
1331
|
+
marker = claim_status_marker(claim.status)
|
|
1332
|
+
active = " current" if claim.active else ""
|
|
1333
|
+
lines.append(
|
|
1334
|
+
f" {marker} {claim.id:<28} {claim.status:<18} "
|
|
1335
|
+
f"evidence {claim.evidence_count}{active}"
|
|
1336
|
+
)
|
|
1337
|
+
lines.append("")
|
|
1338
|
+
lines.append("Current action")
|
|
1339
|
+
if snapshot.current_action is None:
|
|
1340
|
+
if snapshot.phase in {
|
|
1341
|
+
RunPhase.COMPLETED,
|
|
1342
|
+
RunPhase.CANCELLED,
|
|
1343
|
+
RunPhase.PROVIDER_FAILED,
|
|
1344
|
+
RunPhase.BUDGET_EXHAUSTED,
|
|
1345
|
+
}:
|
|
1346
|
+
lines.append(" (campaign has a terminal report)")
|
|
1347
|
+
elif snapshot.phase is RunPhase.PAUSED:
|
|
1348
|
+
lines.append(" (paused at an action boundary; resume to continue)")
|
|
1349
|
+
else:
|
|
1350
|
+
lines.append(" (no pending action)")
|
|
1351
|
+
else:
|
|
1352
|
+
action = snapshot.current_action
|
|
1353
|
+
lines.append(f" {action.description}")
|
|
1354
|
+
details: list[str] = [f"iteration={action.iteration}"]
|
|
1355
|
+
if action.stage:
|
|
1356
|
+
details.append(f"stage={action.stage}")
|
|
1357
|
+
if snapshot.latest_heartbeat and snapshot.latest_heartbeat.elapsed_wall_seconds is not None:
|
|
1358
|
+
details.append(
|
|
1359
|
+
f"elapsed={int(snapshot.latest_heartbeat.elapsed_wall_seconds)} s"
|
|
1360
|
+
)
|
|
1361
|
+
details.append(f"workspace={format_bytes(snapshot.workspace_bytes)}")
|
|
1362
|
+
lines.append(" " + ", ".join(details))
|
|
1363
|
+
lines.append("")
|
|
1364
|
+
lines.append("Recent activity")
|
|
1365
|
+
events = snapshot.recent_events[-8:]
|
|
1366
|
+
if not events:
|
|
1367
|
+
lines.append(" (no transcript events yet)")
|
|
1368
|
+
for event in events:
|
|
1369
|
+
lines.append(f" {event.summary}")
|
|
1370
|
+
if snapshot.report is not None:
|
|
1371
|
+
lines.append("")
|
|
1372
|
+
lines.append(f"Terminal report: {snapshot.report.status}")
|
|
1373
|
+
answer = snapshot.report.final_answer.strip()
|
|
1374
|
+
if answer:
|
|
1375
|
+
lines.append("Final answer")
|
|
1376
|
+
preview = answer if len(answer) <= 1200 else answer[:1197] + "..."
|
|
1377
|
+
for raw in preview.splitlines():
|
|
1378
|
+
lines.append(f" {raw}")
|
|
1379
|
+
if snapshot.report.finish_claim_notes:
|
|
1380
|
+
lines.append("Finish notes")
|
|
1381
|
+
for note in snapshot.report.finish_claim_notes:
|
|
1382
|
+
lines.append(f" {note}")
|
|
1383
|
+
if snapshot.warnings:
|
|
1384
|
+
lines.append("")
|
|
1385
|
+
lines.append("Warnings")
|
|
1386
|
+
for warning in snapshot.warnings:
|
|
1387
|
+
lines.append(f" {warning}")
|
|
1388
|
+
return "\n".join(lines) + "\n"
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def watch_run(
|
|
1392
|
+
run_directory: str | Path,
|
|
1393
|
+
*,
|
|
1394
|
+
jsonl: bool = False,
|
|
1395
|
+
poll_seconds: float = 0.5,
|
|
1396
|
+
output: TextIO | None = None,
|
|
1397
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
1398
|
+
should_stop: Callable[[], bool] | None = None,
|
|
1399
|
+
now: Callable[[], datetime] = utc_now,
|
|
1400
|
+
) -> int:
|
|
1401
|
+
"""Follow durable records until a terminal report appears.
|
|
1402
|
+
|
|
1403
|
+
Interrupting this viewer does not cancel the scientific campaign.
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
stream = output if output is not None else sys.stdout
|
|
1407
|
+
monitor = MVPRunMonitor(run_directory)
|
|
1408
|
+
last_event_sequence = 0
|
|
1409
|
+
last_cursor = TranscriptCursor()
|
|
1410
|
+
last_signature: tuple[Any, ...] | None = None
|
|
1411
|
+
try:
|
|
1412
|
+
while True:
|
|
1413
|
+
snapshot = monitor.snapshot(now=now())
|
|
1414
|
+
cursor = snapshot.transcript_cursor
|
|
1415
|
+
if last_cursor.inode is not None and (
|
|
1416
|
+
cursor.inode != last_cursor.inode or cursor.offset < last_cursor.offset
|
|
1417
|
+
):
|
|
1418
|
+
last_event_sequence = 0
|
|
1419
|
+
last_cursor = cursor
|
|
1420
|
+
events = snapshot.recent_events
|
|
1421
|
+
new_events = tuple(
|
|
1422
|
+
event for event in events if event.sequence > last_event_sequence
|
|
1423
|
+
)
|
|
1424
|
+
if events:
|
|
1425
|
+
last_event_sequence = max(
|
|
1426
|
+
last_event_sequence,
|
|
1427
|
+
max(event.sequence for event in events),
|
|
1428
|
+
)
|
|
1429
|
+
signature = (
|
|
1430
|
+
snapshot.phase,
|
|
1431
|
+
snapshot.iterations,
|
|
1432
|
+
snapshot.current_action.description if snapshot.current_action else None,
|
|
1433
|
+
snapshot.latest_heartbeat.age_seconds if snapshot.latest_heartbeat else None,
|
|
1434
|
+
len(snapshot.claims),
|
|
1435
|
+
snapshot.report.status if snapshot.report else None,
|
|
1436
|
+
)
|
|
1437
|
+
if jsonl:
|
|
1438
|
+
stream.write(snapshot.model_dump_json() + "\n")
|
|
1439
|
+
stream.flush()
|
|
1440
|
+
elif signature != last_signature or new_events:
|
|
1441
|
+
if last_signature is None:
|
|
1442
|
+
stream.write(format_human_status(snapshot))
|
|
1443
|
+
else:
|
|
1444
|
+
for event in new_events:
|
|
1445
|
+
stream.write(f" {event.summary}\n")
|
|
1446
|
+
if signature[0] != last_signature[0] or snapshot.report is not None:
|
|
1447
|
+
stream.write(format_human_status(snapshot))
|
|
1448
|
+
stream.flush()
|
|
1449
|
+
last_signature = signature
|
|
1450
|
+
if snapshot.phase in {
|
|
1451
|
+
RunPhase.COMPLETED,
|
|
1452
|
+
RunPhase.CANCELLED,
|
|
1453
|
+
RunPhase.PROVIDER_FAILED,
|
|
1454
|
+
RunPhase.BUDGET_EXHAUSTED,
|
|
1455
|
+
RunPhase.PAUSED,
|
|
1456
|
+
}:
|
|
1457
|
+
return 0
|
|
1458
|
+
if should_stop is not None and should_stop():
|
|
1459
|
+
return 0
|
|
1460
|
+
sleep(max(0.05, poll_seconds))
|
|
1461
|
+
except KeyboardInterrupt:
|
|
1462
|
+
if not jsonl:
|
|
1463
|
+
stream.write("watch stopped; the campaign was not cancelled\n")
|
|
1464
|
+
stream.flush()
|
|
1465
|
+
return 0
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def installed_capability_descriptors(
|
|
1469
|
+
project_root: str | Path | None = None,
|
|
1470
|
+
) -> tuple[dict[str, Any], ...]:
|
|
1471
|
+
"""Return generic descriptors of currently installed capabilities."""
|
|
1472
|
+
|
|
1473
|
+
from .mvp_skills import discover_builtin_mvp_resources
|
|
1474
|
+
|
|
1475
|
+
_skills, capabilities = discover_builtin_mvp_resources(project_root)
|
|
1476
|
+
descriptors: list[dict[str, Any]] = []
|
|
1477
|
+
for item in capabilities.descriptors():
|
|
1478
|
+
descriptors.append(
|
|
1479
|
+
{
|
|
1480
|
+
"name": item.get("name"),
|
|
1481
|
+
"version": item.get("version"),
|
|
1482
|
+
"description": item.get("description"),
|
|
1483
|
+
"skill": item.get("skill"),
|
|
1484
|
+
"runtime": item.get("runtime"),
|
|
1485
|
+
}
|
|
1486
|
+
)
|
|
1487
|
+
return tuple(descriptors)
|