delegate-agent-cli 0.11.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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import tarfile
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import BinaryIO, TextIO
|
|
8
|
+
|
|
9
|
+
from delegate_agent import command_errors, harness_events, log_output, redaction, run_registry
|
|
10
|
+
from delegate_agent import rendering as delegate_rendering
|
|
11
|
+
from delegate_agent import retention as delegate_retention
|
|
12
|
+
from delegate_agent.json_types import JsonObject
|
|
13
|
+
from delegate_agent.log_output import RUN_OUTPUT_DEFAULT_MAX_CHARS
|
|
14
|
+
|
|
15
|
+
RECOVERY_STDOUT_TAIL_LINES = 2000
|
|
16
|
+
RECOVERY_STDOUT_TAIL_BYTES = 1_000_000
|
|
17
|
+
RUN_OUTPUT_DEFAULT_TAIL_LINES = 80
|
|
18
|
+
STREAM_READ_CHUNK_KIB = 64
|
|
19
|
+
STREAM_READ_CHUNK_BYTES = STREAM_READ_CHUNK_KIB * run_registry.BYTES_PER_KIB
|
|
20
|
+
RESULT_QUALITY_OK = harness_events.RESULT_QUALITY_OK
|
|
21
|
+
RESULT_QUALITY_HOUSEKEEPING = harness_events.RESULT_QUALITY_HOUSEKEEPING
|
|
22
|
+
RESULT_QUALITY_EMPTY = harness_events.RESULT_QUALITY_EMPTY
|
|
23
|
+
RESULT_QUALITY_SUSPECT_SHORT = harness_events.RESULT_QUALITY_SUSPECT_SHORT
|
|
24
|
+
RESULT_QUALITY_NO_ASSISTANT_TEXT = harness_events.RESULT_QUALITY_NO_ASSISTANT_TEXT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RunOutputCommand:
|
|
29
|
+
handle: str | None
|
|
30
|
+
latest_harness: str | None = None
|
|
31
|
+
json_mode: bool = False
|
|
32
|
+
completion_report: bool = False
|
|
33
|
+
stdout: bool = False
|
|
34
|
+
stderr: bool = False
|
|
35
|
+
tail: int | None = None
|
|
36
|
+
max_chars: int | None = None
|
|
37
|
+
raw: bool = False
|
|
38
|
+
no_redact: bool = False
|
|
39
|
+
default: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunOutputError(command_errors.CommandError):
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
error: str,
|
|
46
|
+
message: str,
|
|
47
|
+
*,
|
|
48
|
+
diagnostics: JsonObject | None = None,
|
|
49
|
+
next_actions: list[str] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(error, message)
|
|
52
|
+
self.diagnostics = diagnostics
|
|
53
|
+
self.next_actions = next_actions
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _effective_max_chars(command: RunOutputCommand) -> int | None:
|
|
57
|
+
if command.raw:
|
|
58
|
+
return None
|
|
59
|
+
return command.max_chars if command.max_chars is not None else RUN_OUTPUT_DEFAULT_MAX_CHARS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _add_log_output_section(
|
|
63
|
+
*,
|
|
64
|
+
registry_root: Path,
|
|
65
|
+
run_id: str,
|
|
66
|
+
log_name: str,
|
|
67
|
+
section_name: str,
|
|
68
|
+
tail: int | None,
|
|
69
|
+
raw: bool,
|
|
70
|
+
max_chars: int | None,
|
|
71
|
+
sections: JsonObject,
|
|
72
|
+
text_sections: dict[str, str],
|
|
73
|
+
) -> None:
|
|
74
|
+
output = delegate_retention.read_log_output(
|
|
75
|
+
registry_root,
|
|
76
|
+
run_id,
|
|
77
|
+
log_name,
|
|
78
|
+
tail=tail,
|
|
79
|
+
raw=raw,
|
|
80
|
+
)
|
|
81
|
+
content = output.content
|
|
82
|
+
meta: JsonObject = {
|
|
83
|
+
"bytes": delegate_retention.log_file_byte_size(registry_root, run_id, log_name),
|
|
84
|
+
"truncated": output.truncated,
|
|
85
|
+
"archived": delegate_retention.raw_logs_archived(registry_root, run_id),
|
|
86
|
+
}
|
|
87
|
+
if raw:
|
|
88
|
+
meta["rawOutputBytes"] = meta["bytes"]
|
|
89
|
+
if tail is not None and not raw:
|
|
90
|
+
meta["tailLines"] = tail
|
|
91
|
+
if max_chars is not None:
|
|
92
|
+
capped = log_output.cap_content_by_chars(content, max_chars)
|
|
93
|
+
content = capped.content
|
|
94
|
+
meta["maxChars"] = max_chars
|
|
95
|
+
meta["charTruncated"] = capped.char_truncated
|
|
96
|
+
meta["returnedChars"] = capped.returned_chars
|
|
97
|
+
meta["omittedChars"] = capped.omitted_chars
|
|
98
|
+
sections[section_name] = meta
|
|
99
|
+
text_sections[section_name] = content
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decode_recovery_tail(data: bytes, *, truncated: bool) -> tuple[str, bool]:
|
|
103
|
+
if truncated:
|
|
104
|
+
newline = data.find(b"\n")
|
|
105
|
+
if newline < 0:
|
|
106
|
+
return "", True
|
|
107
|
+
data = data[newline + 1 :]
|
|
108
|
+
text = data.decode("utf-8", errors="replace")
|
|
109
|
+
lines = text.splitlines()
|
|
110
|
+
if not lines:
|
|
111
|
+
return "", truncated
|
|
112
|
+
trimmed = lines[-RECOVERY_STDOUT_TAIL_LINES:]
|
|
113
|
+
return "\n".join(trimmed) + "\n", truncated or len(lines) > len(trimmed)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_stream_tail_bytes(stream: BinaryIO, byte_limit: int) -> bytes:
|
|
117
|
+
buffer = bytearray()
|
|
118
|
+
while True:
|
|
119
|
+
chunk = stream.read(STREAM_READ_CHUNK_BYTES)
|
|
120
|
+
if not chunk:
|
|
121
|
+
break
|
|
122
|
+
buffer.extend(chunk)
|
|
123
|
+
if len(buffer) > byte_limit:
|
|
124
|
+
del buffer[: len(buffer) - byte_limit]
|
|
125
|
+
return bytes(buffer)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_archived_recovery_stdout_tail(registry_root: Path, run_id: str) -> tuple[str, bool]:
|
|
129
|
+
archive_file = delegate_retention.archive_path(registry_root, run_id)
|
|
130
|
+
if not archive_file.exists():
|
|
131
|
+
return "", False
|
|
132
|
+
with tarfile.open(archive_file, "r:gz") as archive:
|
|
133
|
+
try:
|
|
134
|
+
member = archive.getmember(run_registry.STDOUT_LOG)
|
|
135
|
+
except KeyError:
|
|
136
|
+
return "", False
|
|
137
|
+
extracted = archive.extractfile(member)
|
|
138
|
+
if extracted is None:
|
|
139
|
+
return "", False
|
|
140
|
+
data = _read_stream_tail_bytes(extracted, RECOVERY_STDOUT_TAIL_BYTES)
|
|
141
|
+
return _decode_recovery_tail(data, truncated=member.size > len(data))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_recovery_stdout_tail(registry_root: Path, run_id: str) -> tuple[str, bool]:
|
|
145
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
146
|
+
log_path = run_path / run_registry.STDOUT_LOG
|
|
147
|
+
if not log_path.exists():
|
|
148
|
+
return _read_archived_recovery_stdout_tail(registry_root, run_id)
|
|
149
|
+
with log_path.open("rb") as handle:
|
|
150
|
+
handle.seek(0, 2)
|
|
151
|
+
end = handle.tell()
|
|
152
|
+
if end == 0:
|
|
153
|
+
return "", False
|
|
154
|
+
start = max(0, end - RECOVERY_STDOUT_TAIL_BYTES)
|
|
155
|
+
handle.seek(start)
|
|
156
|
+
data = handle.read(end - start)
|
|
157
|
+
return _decode_recovery_tail(data, truncated=start > 0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _recover_completion_report_from_stdout(
|
|
161
|
+
registry_root: Path,
|
|
162
|
+
run_id: str,
|
|
163
|
+
*,
|
|
164
|
+
harness: str | None = None,
|
|
165
|
+
allow_last_assistant: bool = False,
|
|
166
|
+
) -> tuple[str, bool, harness_events.RecoveryQuality | None]:
|
|
167
|
+
# The completion is the final turn's closing message, which lives at the end of
|
|
168
|
+
# the stream, so a bounded tail is sufficient and avoids loading a possibly
|
|
169
|
+
# huge stdout.log into memory on a routine run-output call. The byte bound is
|
|
170
|
+
# as important as the line bound because Codex JSONL can encode large command
|
|
171
|
+
# output inside a single physical line.
|
|
172
|
+
stdout_text, truncated = _read_recovery_stdout_tail(registry_root, run_id)
|
|
173
|
+
if not stdout_text:
|
|
174
|
+
return "", truncated, None
|
|
175
|
+
accumulator = harness_events.StreamAccumulator(harness=harness)
|
|
176
|
+
for line in stdout_text.split("\n"):
|
|
177
|
+
accumulator.ingest_line(line)
|
|
178
|
+
if accumulator.completion_text:
|
|
179
|
+
return accumulator.completion_text.strip(), truncated, "explicit_completion"
|
|
180
|
+
if allow_last_assistant and accumulator.recoverable_assistant_text:
|
|
181
|
+
quality = accumulator.assistant_recovery_quality()
|
|
182
|
+
return accumulator.recoverable_assistant_text.strip(), truncated, quality
|
|
183
|
+
return "", truncated, None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _run_output_next_actions(handle: str) -> list[str]:
|
|
187
|
+
quoted = shlex.quote(handle)
|
|
188
|
+
return [
|
|
189
|
+
f"delegate run-output {quoted} --stdout --tail {RUN_OUTPUT_DEFAULT_TAIL_LINES}",
|
|
190
|
+
f"delegate run-output {quoted} --stderr --tail {RUN_OUTPUT_DEFAULT_TAIL_LINES}",
|
|
191
|
+
f"delegate run-output {quoted} --raw",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _completion_report_source(registry_root: Path, run_id: str) -> str | None:
|
|
196
|
+
state = run_registry.load_run_state(registry_root, run_id)
|
|
197
|
+
source = state.get("completionReportSource")
|
|
198
|
+
return source if isinstance(source, str) else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _completion_report_quality(
|
|
202
|
+
text: str,
|
|
203
|
+
*,
|
|
204
|
+
registry_root: Path,
|
|
205
|
+
run_id: str,
|
|
206
|
+
report_source: str | None = None,
|
|
207
|
+
) -> str:
|
|
208
|
+
quality = harness_events.assistant_recovery_quality_for_text(text)
|
|
209
|
+
if quality == "housekeeping_fallback" and harness_events.is_housekeeping_assistant_text(text):
|
|
210
|
+
return RESULT_QUALITY_HOUSEKEEPING
|
|
211
|
+
manifest = run_registry.load_run_manifest(registry_root, run_id)
|
|
212
|
+
# suspect_short applies ONLY to genuine child reports that are short AND not
|
|
213
|
+
# substantive: a terse but substantive "Verdict:/Status:" report must NOT be
|
|
214
|
+
# flagged, while a preamble-only fragment must still flag. Delegate-synthesized
|
|
215
|
+
# and stdout-recovery reports are never suspect.
|
|
216
|
+
if (
|
|
217
|
+
manifest.get("mode") == "safe"
|
|
218
|
+
and report_source == "child"
|
|
219
|
+
and len(text.strip()) < 200
|
|
220
|
+
and not harness_events.is_substantive_assistant_text(text)
|
|
221
|
+
):
|
|
222
|
+
return RESULT_QUALITY_SUSPECT_SHORT
|
|
223
|
+
return RESULT_QUALITY_OK
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_completion_report_quality(
|
|
227
|
+
text: str,
|
|
228
|
+
*,
|
|
229
|
+
registry_root: Path,
|
|
230
|
+
run_id: str,
|
|
231
|
+
report_source: str | None = None,
|
|
232
|
+
) -> str:
|
|
233
|
+
# Read-time classification prefers the stored state.resultQuality the runner
|
|
234
|
+
# already computed at write time, and recomputes from disk only when state
|
|
235
|
+
# lacks it (e.g. older runs persisted before the field existed).
|
|
236
|
+
state = run_registry.load_run_state(registry_root, run_id)
|
|
237
|
+
stored = state.get("resultQuality") if isinstance(state, dict) else None
|
|
238
|
+
if isinstance(stored, str) and stored:
|
|
239
|
+
return stored
|
|
240
|
+
return _completion_report_quality(
|
|
241
|
+
text,
|
|
242
|
+
registry_root=registry_root,
|
|
243
|
+
run_id=run_id,
|
|
244
|
+
report_source=report_source,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Delegate to the shared helper in harness_events so the runner (write-time) and
|
|
249
|
+
# run-output (read-time) channels emit identical warning text.
|
|
250
|
+
_quality_warning = harness_events.quality_warning
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _append_warning(warnings: list[str], warning: str | None) -> None:
|
|
254
|
+
if warning and warning not in warnings:
|
|
255
|
+
warnings.append(warning)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _log_section_info(registry_root: Path, run_id: str, log_name: str) -> JsonObject:
|
|
259
|
+
"""Probe a stream's presence and byte size once (live file first, then archive)."""
|
|
260
|
+
live_path = run_registry.run_directory(registry_root, run_id) / log_name
|
|
261
|
+
if live_path.exists():
|
|
262
|
+
return {"present": True, "bytes": live_path.stat().st_size}
|
|
263
|
+
size = delegate_retention.log_file_byte_size(registry_root, run_id, log_name)
|
|
264
|
+
return {"present": size > 0, "bytes": size}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _run_output_diagnostics(
|
|
268
|
+
registry_root: Path,
|
|
269
|
+
run_id: str,
|
|
270
|
+
*,
|
|
271
|
+
recovery_attempted: bool = False,
|
|
272
|
+
recovery_truncated: bool = False,
|
|
273
|
+
recovery_quality: harness_events.RecoveryQuality | None = None,
|
|
274
|
+
) -> JsonObject:
|
|
275
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
276
|
+
state = run_registry.load_run_state(registry_root, run_id)
|
|
277
|
+
report_path = run_path / run_registry.COMPLETION_REPORT_FILE
|
|
278
|
+
diagnostics: JsonObject = {
|
|
279
|
+
"status": run_registry.effective_status(state),
|
|
280
|
+
"rawLogsArchived": delegate_retention.raw_logs_archived(registry_root, run_id),
|
|
281
|
+
"completionReport": {
|
|
282
|
+
"present": report_path.exists(),
|
|
283
|
+
"bytes": report_path.stat().st_size if report_path.exists() else 0,
|
|
284
|
+
},
|
|
285
|
+
"stdout": _log_section_info(registry_root, run_id, run_registry.STDOUT_LOG),
|
|
286
|
+
"stderr": _log_section_info(registry_root, run_id, run_registry.STDERR_LOG),
|
|
287
|
+
}
|
|
288
|
+
if recovery_attempted:
|
|
289
|
+
recovery_meta: JsonObject = {
|
|
290
|
+
"attempted": True,
|
|
291
|
+
"source": run_registry.STDOUT_LOG,
|
|
292
|
+
"tailLines": RECOVERY_STDOUT_TAIL_LINES,
|
|
293
|
+
"tailBytes": RECOVERY_STDOUT_TAIL_BYTES,
|
|
294
|
+
"truncated": recovery_truncated,
|
|
295
|
+
}
|
|
296
|
+
if recovery_quality is not None:
|
|
297
|
+
recovery_meta["quality"] = recovery_quality
|
|
298
|
+
diagnostics["recovery"] = recovery_meta
|
|
299
|
+
return diagnostics
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _format_run_output_diagnostics(diagnostics: JsonObject, next_actions: list[str]) -> str:
|
|
303
|
+
stdout_info = diagnostics.get("stdout") if isinstance(diagnostics.get("stdout"), dict) else {}
|
|
304
|
+
stderr_info = diagnostics.get("stderr") if isinstance(diagnostics.get("stderr"), dict) else {}
|
|
305
|
+
recovery = diagnostics.get("recovery") if isinstance(diagnostics.get("recovery"), dict) else {}
|
|
306
|
+
lines = [
|
|
307
|
+
"No completion report or recoverable final message found.",
|
|
308
|
+
f"status: {diagnostics.get('status', 'unknown')}",
|
|
309
|
+
f"stdout: present={stdout_info.get('present', False)} bytes={stdout_info.get('bytes', 0)}",
|
|
310
|
+
f"stderr: present={stderr_info.get('present', False)} bytes={stderr_info.get('bytes', 0)}",
|
|
311
|
+
]
|
|
312
|
+
if recovery:
|
|
313
|
+
lines.append(f"recovery: truncated={recovery.get('truncated', False)}")
|
|
314
|
+
if recovery.get("quality"):
|
|
315
|
+
lines.append(f"recovery quality: {recovery.get('quality')}")
|
|
316
|
+
lines.append("next actions:")
|
|
317
|
+
lines.extend(f" - {action}" for action in next_actions)
|
|
318
|
+
return "\n".join(lines) + "\n"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _add_default_run_output_fallback(
|
|
322
|
+
*,
|
|
323
|
+
registry_root: Path,
|
|
324
|
+
run_id: str,
|
|
325
|
+
alias: str | None,
|
|
326
|
+
sections: JsonObject,
|
|
327
|
+
text_sections: dict[str, str],
|
|
328
|
+
recovery_attempted: bool,
|
|
329
|
+
recovery_truncated: bool,
|
|
330
|
+
recovery_quality: harness_events.RecoveryQuality | None = None,
|
|
331
|
+
) -> None:
|
|
332
|
+
diagnostics = _run_output_diagnostics(
|
|
333
|
+
registry_root,
|
|
334
|
+
run_id,
|
|
335
|
+
recovery_attempted=recovery_attempted,
|
|
336
|
+
recovery_truncated=recovery_truncated,
|
|
337
|
+
recovery_quality=recovery_quality,
|
|
338
|
+
)
|
|
339
|
+
for log_name, section_name in (
|
|
340
|
+
(run_registry.STDOUT_LOG, "stdout"),
|
|
341
|
+
(run_registry.STDERR_LOG, "stderr"),
|
|
342
|
+
):
|
|
343
|
+
stream_info = diagnostics.get(section_name)
|
|
344
|
+
if not (isinstance(stream_info, dict) and stream_info.get("present")):
|
|
345
|
+
continue
|
|
346
|
+
_add_log_output_section(
|
|
347
|
+
registry_root=registry_root,
|
|
348
|
+
run_id=run_id,
|
|
349
|
+
log_name=log_name,
|
|
350
|
+
section_name=section_name,
|
|
351
|
+
tail=RUN_OUTPUT_DEFAULT_TAIL_LINES,
|
|
352
|
+
raw=False,
|
|
353
|
+
max_chars=RUN_OUTPUT_DEFAULT_MAX_CHARS,
|
|
354
|
+
sections=sections,
|
|
355
|
+
text_sections=text_sections,
|
|
356
|
+
)
|
|
357
|
+
handle = alias or run_id
|
|
358
|
+
next_actions = _run_output_next_actions(handle)
|
|
359
|
+
diagnostics["nextActions"] = next_actions
|
|
360
|
+
content = _format_run_output_diagnostics(diagnostics, next_actions)
|
|
361
|
+
sections["diagnostics"] = {"bytes": len(content.encode("utf-8"))}
|
|
362
|
+
text_sections["diagnostics"] = content
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _add_completion_report_section(
|
|
366
|
+
command: RunOutputCommand,
|
|
367
|
+
*,
|
|
368
|
+
registry_root: Path,
|
|
369
|
+
run_id: str,
|
|
370
|
+
alias: str | None,
|
|
371
|
+
sections: JsonObject,
|
|
372
|
+
text_sections: dict[str, str],
|
|
373
|
+
warnings: list[str],
|
|
374
|
+
) -> None:
|
|
375
|
+
if not command.completion_report:
|
|
376
|
+
return
|
|
377
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
378
|
+
report_path = run_path / run_registry.COMPLETION_REPORT_FILE
|
|
379
|
+
if report_path.exists():
|
|
380
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
381
|
+
manifest = run_registry.load_run_manifest(registry_root, run_id)
|
|
382
|
+
harness = manifest.get("harness") if isinstance(manifest, dict) else None
|
|
383
|
+
source = _completion_report_source(registry_root, run_id)
|
|
384
|
+
result_quality = _resolve_completion_report_quality(
|
|
385
|
+
text,
|
|
386
|
+
registry_root=registry_root,
|
|
387
|
+
run_id=run_id,
|
|
388
|
+
report_source=source,
|
|
389
|
+
)
|
|
390
|
+
_append_warning(
|
|
391
|
+
warnings,
|
|
392
|
+
_quality_warning(result_quality, harness=harness if isinstance(harness, str) else None),
|
|
393
|
+
)
|
|
394
|
+
meta: JsonObject = {
|
|
395
|
+
"bytes": len(text.encode("utf-8")),
|
|
396
|
+
"resultQuality": result_quality,
|
|
397
|
+
}
|
|
398
|
+
if source is not None:
|
|
399
|
+
meta["completionReportSource"] = source
|
|
400
|
+
sections["completionReport"] = meta
|
|
401
|
+
text_sections["completionReport"] = text
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
status = run_registry.effective_status(run_registry.load_run_state(registry_root, run_id))
|
|
405
|
+
text = ""
|
|
406
|
+
recovery_truncated = False
|
|
407
|
+
recovery_attempted = False
|
|
408
|
+
recovery_quality: harness_events.RecoveryQuality | None = None
|
|
409
|
+
if status != run_registry.STATUS_RUNNING:
|
|
410
|
+
manifest = run_registry.load_run_manifest(registry_root, run_id)
|
|
411
|
+
harness = manifest.get("harness") if isinstance(manifest, dict) else None
|
|
412
|
+
allow_last_assistant = harness in harness_events.ASSISTANT_RECOVERY_HARNESSES
|
|
413
|
+
recovery_attempted = True
|
|
414
|
+
text, recovery_truncated, recovery_quality = _recover_completion_report_from_stdout(
|
|
415
|
+
registry_root,
|
|
416
|
+
run_id,
|
|
417
|
+
harness=harness if isinstance(harness, str) else None,
|
|
418
|
+
allow_last_assistant=allow_last_assistant,
|
|
419
|
+
)
|
|
420
|
+
if text and recovery_quality != "housekeeping_fallback":
|
|
421
|
+
report_meta: JsonObject = {
|
|
422
|
+
"bytes": len(text.encode("utf-8")),
|
|
423
|
+
"source": run_registry.STDOUT_LOG,
|
|
424
|
+
"synthetic": True,
|
|
425
|
+
"tailLines": RECOVERY_STDOUT_TAIL_LINES,
|
|
426
|
+
"tailBytes": RECOVERY_STDOUT_TAIL_BYTES,
|
|
427
|
+
"truncated": recovery_truncated,
|
|
428
|
+
}
|
|
429
|
+
if recovery_quality is not None:
|
|
430
|
+
report_meta["recoveryQuality"] = recovery_quality
|
|
431
|
+
result_quality = (
|
|
432
|
+
RESULT_QUALITY_HOUSEKEEPING
|
|
433
|
+
if recovery_quality == "housekeeping_fallback"
|
|
434
|
+
else _resolve_completion_report_quality(
|
|
435
|
+
text,
|
|
436
|
+
registry_root=registry_root,
|
|
437
|
+
run_id=run_id,
|
|
438
|
+
report_source="stdout_recovery",
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
report_meta["resultQuality"] = result_quality
|
|
442
|
+
report_meta["completionReportSource"] = "stdout_recovery"
|
|
443
|
+
manifest = run_registry.load_run_manifest(registry_root, run_id)
|
|
444
|
+
harness = manifest.get("harness") if isinstance(manifest, dict) else None
|
|
445
|
+
_append_warning(
|
|
446
|
+
warnings,
|
|
447
|
+
_quality_warning(result_quality, harness=harness if isinstance(harness, str) else None),
|
|
448
|
+
)
|
|
449
|
+
sections["completionReport"] = report_meta
|
|
450
|
+
text_sections["completionReport"] = text
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
if command.default:
|
|
454
|
+
_add_default_run_output_fallback(
|
|
455
|
+
registry_root=registry_root,
|
|
456
|
+
run_id=run_id,
|
|
457
|
+
alias=alias,
|
|
458
|
+
sections=sections,
|
|
459
|
+
text_sections=text_sections,
|
|
460
|
+
recovery_attempted=recovery_attempted,
|
|
461
|
+
recovery_truncated=recovery_truncated,
|
|
462
|
+
recovery_quality=recovery_quality,
|
|
463
|
+
)
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
handle = alias or run_id
|
|
467
|
+
diagnostics = _run_output_diagnostics(
|
|
468
|
+
registry_root,
|
|
469
|
+
run_id,
|
|
470
|
+
recovery_attempted=recovery_attempted,
|
|
471
|
+
recovery_truncated=recovery_truncated,
|
|
472
|
+
recovery_quality=recovery_quality,
|
|
473
|
+
)
|
|
474
|
+
raise RunOutputError(
|
|
475
|
+
"missing_completion_report",
|
|
476
|
+
f"Completion report not found for run: {handle}",
|
|
477
|
+
diagnostics=diagnostics,
|
|
478
|
+
next_actions=_run_output_next_actions(handle),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _add_default_fallback_if_requested(
|
|
483
|
+
command: RunOutputCommand,
|
|
484
|
+
*,
|
|
485
|
+
registry_root: Path,
|
|
486
|
+
run_id: str,
|
|
487
|
+
alias: str | None,
|
|
488
|
+
sections: JsonObject,
|
|
489
|
+
text_sections: dict[str, str],
|
|
490
|
+
) -> None:
|
|
491
|
+
if not (command.default and not sections):
|
|
492
|
+
return
|
|
493
|
+
_add_default_run_output_fallback(
|
|
494
|
+
registry_root=registry_root,
|
|
495
|
+
run_id=run_id,
|
|
496
|
+
alias=alias,
|
|
497
|
+
sections=sections,
|
|
498
|
+
text_sections=text_sections,
|
|
499
|
+
recovery_attempted=False,
|
|
500
|
+
recovery_truncated=False,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _redact_text_sections(
|
|
505
|
+
text_sections: dict[str, str],
|
|
506
|
+
*,
|
|
507
|
+
no_redact: bool,
|
|
508
|
+
) -> dict[str, str]:
|
|
509
|
+
if no_redact:
|
|
510
|
+
return text_sections
|
|
511
|
+
return {key: redaction.redact_string(text) for key, text in text_sections.items()}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _merge_json_sections(sections: JsonObject, text_sections: dict[str, str]) -> JsonObject:
|
|
515
|
+
merged_sections: JsonObject = {}
|
|
516
|
+
for key, meta in sections.items():
|
|
517
|
+
entry = dict(meta)
|
|
518
|
+
if key in text_sections:
|
|
519
|
+
entry["content"] = text_sections[key]
|
|
520
|
+
merged_sections[key] = entry
|
|
521
|
+
return merged_sections
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _emit_run_output_sections(
|
|
525
|
+
command: RunOutputCommand,
|
|
526
|
+
*,
|
|
527
|
+
alias: str | None,
|
|
528
|
+
run_id: str,
|
|
529
|
+
sections: JsonObject,
|
|
530
|
+
text_sections: dict[str, str],
|
|
531
|
+
resolution: JsonObject | None,
|
|
532
|
+
warnings: list[str],
|
|
533
|
+
stdout: TextIO,
|
|
534
|
+
) -> None:
|
|
535
|
+
if command.json_mode:
|
|
536
|
+
payload = delegate_rendering.run_output_json_payload(
|
|
537
|
+
alias=alias,
|
|
538
|
+
run_id=run_id,
|
|
539
|
+
sections=_merge_json_sections(sections, text_sections),
|
|
540
|
+
resolution=resolution,
|
|
541
|
+
warnings=warnings,
|
|
542
|
+
)
|
|
543
|
+
delegate_rendering.print_json(payload, stdout)
|
|
544
|
+
return
|
|
545
|
+
delegate_rendering.render_run_output_text(
|
|
546
|
+
text_sections,
|
|
547
|
+
stdout,
|
|
548
|
+
section_meta=sections,
|
|
549
|
+
resolution=resolution,
|
|
550
|
+
warnings=warnings,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def emit(command: RunOutputCommand, *, workspace_path: str, stdout: TextIO) -> int:
|
|
555
|
+
workspace = Path(workspace_path)
|
|
556
|
+
registry_root = run_registry.registry_root_if_exists(workspace)
|
|
557
|
+
if registry_root is None:
|
|
558
|
+
registry_root = run_registry.registry_root(workspace)
|
|
559
|
+
target = run_registry.resolve_run_target(
|
|
560
|
+
registry_root,
|
|
561
|
+
handle=command.handle,
|
|
562
|
+
latest_harness=command.latest_harness,
|
|
563
|
+
)
|
|
564
|
+
if isinstance(target, run_registry.RunTargetLookupError):
|
|
565
|
+
raise RunOutputError(target.error, target.message)
|
|
566
|
+
run_id, alias = target.run_id, target.alias
|
|
567
|
+
resolution = (
|
|
568
|
+
{
|
|
569
|
+
"requestedHandle": target.requested_handle,
|
|
570
|
+
"resolvedHandle": target.resolved_handle or alias or run_id,
|
|
571
|
+
"resolutionKind": target.resolution_kind,
|
|
572
|
+
}
|
|
573
|
+
if target.resolution_kind != "literal"
|
|
574
|
+
else None
|
|
575
|
+
)
|
|
576
|
+
sections: JsonObject = {}
|
|
577
|
+
text_sections: dict[str, str] = {}
|
|
578
|
+
# Seed warnings from persisted state so re-emitted quality warnings dedupe
|
|
579
|
+
# against warnings the runner already recorded at write time (cross-channel
|
|
580
|
+
# duplicate suppression).
|
|
581
|
+
warnings: list[str] = []
|
|
582
|
+
state = run_registry.load_run_state(registry_root, run_id)
|
|
583
|
+
if isinstance(state, dict):
|
|
584
|
+
state_warnings = state.get("warnings")
|
|
585
|
+
if isinstance(state_warnings, list):
|
|
586
|
+
for warning in state_warnings:
|
|
587
|
+
if isinstance(warning, str) and warning not in warnings:
|
|
588
|
+
warnings.append(warning)
|
|
589
|
+
_add_completion_report_section(
|
|
590
|
+
command,
|
|
591
|
+
registry_root=registry_root,
|
|
592
|
+
run_id=run_id,
|
|
593
|
+
alias=alias,
|
|
594
|
+
sections=sections,
|
|
595
|
+
text_sections=text_sections,
|
|
596
|
+
warnings=warnings,
|
|
597
|
+
)
|
|
598
|
+
_add_default_fallback_if_requested(
|
|
599
|
+
command,
|
|
600
|
+
registry_root=registry_root,
|
|
601
|
+
run_id=run_id,
|
|
602
|
+
alias=alias,
|
|
603
|
+
sections=sections,
|
|
604
|
+
text_sections=text_sections,
|
|
605
|
+
)
|
|
606
|
+
max_chars = _effective_max_chars(command)
|
|
607
|
+
try:
|
|
608
|
+
if command.stdout or command.raw:
|
|
609
|
+
_add_log_output_section(
|
|
610
|
+
registry_root=registry_root,
|
|
611
|
+
run_id=run_id,
|
|
612
|
+
log_name=run_registry.STDOUT_LOG,
|
|
613
|
+
section_name="stdout",
|
|
614
|
+
tail=command.tail,
|
|
615
|
+
raw=command.raw,
|
|
616
|
+
max_chars=None if command.raw else max_chars,
|
|
617
|
+
sections=sections,
|
|
618
|
+
text_sections=text_sections,
|
|
619
|
+
)
|
|
620
|
+
if command.stderr or command.raw:
|
|
621
|
+
_add_log_output_section(
|
|
622
|
+
registry_root=registry_root,
|
|
623
|
+
run_id=run_id,
|
|
624
|
+
log_name=run_registry.STDERR_LOG,
|
|
625
|
+
section_name="stderr",
|
|
626
|
+
tail=command.tail,
|
|
627
|
+
raw=command.raw,
|
|
628
|
+
max_chars=None if command.raw else max_chars,
|
|
629
|
+
sections=sections,
|
|
630
|
+
text_sections=text_sections,
|
|
631
|
+
)
|
|
632
|
+
except ValueError as exc:
|
|
633
|
+
raise RunOutputError("missing_tail", str(exc)) from exc
|
|
634
|
+
text_sections = _redact_text_sections(text_sections, no_redact=command.no_redact)
|
|
635
|
+
_emit_run_output_sections(
|
|
636
|
+
command,
|
|
637
|
+
alias=alias,
|
|
638
|
+
run_id=run_id,
|
|
639
|
+
sections=sections,
|
|
640
|
+
text_sections=text_sections,
|
|
641
|
+
resolution=resolution,
|
|
642
|
+
warnings=warnings,
|
|
643
|
+
stdout=stdout,
|
|
644
|
+
)
|
|
645
|
+
return 0
|