agentdir-cli 0.7.5__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.
agentdir/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AgentDir local agent work mailstore."""
2
+
3
+ __version__ = "0.7.5"
agentdir/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
agentdir/actors.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .envelope import build_envelope, envelope_bytes
6
+ from .mailbox import atomic_deliver
7
+ from .redaction import redact_text
8
+ from .store import actor_mailbox
9
+
10
+
11
+ def create_actor(root: str | Path, actor_id: str) -> tuple[Path, Path]:
12
+ inbox = actor_mailbox(root, actor_id, "inbox", create=True)
13
+ outbox = actor_mailbox(root, actor_id, "outbox", create=True)
14
+ return inbox, outbox
15
+
16
+
17
+ def send_message(
18
+ *,
19
+ root: str | Path,
20
+ from_actor: str,
21
+ to_actor: str,
22
+ event_type: str,
23
+ body: str,
24
+ subject: str | None = None,
25
+ session_id: str | None = None,
26
+ task_id: str | None = None,
27
+ message_id: str | None = None,
28
+ ) -> tuple[Path, Path]:
29
+ create_actor(root, from_actor)
30
+ create_actor(root, to_actor)
31
+ redacted = redact_text(body)
32
+ extra_headers = {}
33
+ if redacted.replacements:
34
+ extra_headers["X-AgentDir-Redactions"] = str(redacted.replacements)
35
+ extra_headers["X-AgentDir-Redaction-Labels"] = ",".join(redacted.labels)
36
+ msg = build_envelope(
37
+ event_type=event_type,
38
+ body=redacted.text,
39
+ subject=subject,
40
+ from_actor=from_actor,
41
+ to_actor=to_actor,
42
+ session_id=session_id,
43
+ task_id=task_id,
44
+ extra_headers=extra_headers,
45
+ message_id=message_id,
46
+ )
47
+ data = envelope_bytes(msg)
48
+ inbox_path = atomic_deliver(actor_mailbox(root, to_actor, "inbox"), data)
49
+ outbox_path = atomic_deliver(actor_mailbox(root, from_actor, "outbox"), data)
50
+ return inbox_path, outbox_path
agentdir/artifacts.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import mimetypes
5
+ import os
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .mailbox import fsync_directory
11
+ from .store import require_root
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Artifact:
16
+ sha256: str
17
+ path: Path
18
+ bytes: int
19
+ mime_type: str
20
+
21
+
22
+ def artifact_path(root: str | Path, sha256: str) -> Path:
23
+ paths = require_root(root)
24
+ return paths.artifacts / "blobs" / "sha256" / sha256[:2] / sha256[2:4] / sha256
25
+
26
+
27
+ def add_artifact(root: str | Path, source: str | Path) -> Artifact:
28
+ source_path = Path(source).expanduser().resolve()
29
+ digest = hashlib.sha256()
30
+ size = 0
31
+ with source_path.open("rb") as handle:
32
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
33
+ size += len(chunk)
34
+ digest.update(chunk)
35
+ sha = digest.hexdigest()
36
+ target = artifact_path(root, sha)
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ if not target.exists():
39
+ temp = target.with_name(f".{target.name}.{os.getpid()}.tmp")
40
+ with source_path.open("rb") as src, temp.open("wb") as dst:
41
+ shutil.copyfileobj(src, dst)
42
+ dst.flush()
43
+ os.fsync(dst.fileno())
44
+ os.replace(temp, target)
45
+ fsync_directory(target.parent)
46
+ mime_type = mimetypes.guess_type(source_path.name)[0] or "application/octet-stream"
47
+ return Artifact(sha256=sha, path=target, bytes=size, mime_type=mime_type)
48
+
49
+
50
+ def artifact_headers(artifact: Artifact) -> dict[str, str]:
51
+ return {
52
+ "X-AgentDir-Blob-SHA256": artifact.sha256,
53
+ "X-AgentDir-Blob-Bytes": str(artifact.bytes),
54
+ "X-AgentDir-Blob-Mime": artifact.mime_type,
55
+ }
56
+
agentdir/audit.py ADDED
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .context import audit_context_pack
8
+ from .doctor import run_doctor
9
+ from .git import git_status_short
10
+ from .review import EVIDENCE_FAMILIES, evidence_rows, summarize_session
11
+ from .sessions import read_current_session
12
+ from .store import AgentDirError
13
+
14
+ CHECK_PASS = "pass"
15
+ CHECK_WARN = "warn"
16
+ CHECK_FAIL = "fail"
17
+ CHECK_NOT_APPLICABLE = "not_applicable"
18
+ CHECK_STATUSES = (CHECK_PASS, CHECK_WARN, CHECK_FAIL, CHECK_NOT_APPLICABLE)
19
+
20
+ CLAIM_SUPPORTED = "supported"
21
+ CLAIM_UNSUPPORTED = "unsupported"
22
+ CLAIM_CONTRADICTED = "contradicted"
23
+ CLAIM_STATUSES = (CLAIM_SUPPORTED, CLAIM_UNSUPPORTED, CLAIM_CONTRADICTED)
24
+
25
+ CLAIM_FAMILIES = tuple(family for family in EVIDENCE_FAMILIES if family != "diagnostic")
26
+
27
+ CLAIM_KEYWORDS = {
28
+ "test": ("test", "tests", "pytest", "vitest", "jest", "mocha", "unittest"),
29
+ "lint": ("lint", "eslint", "ruff", "flake8"),
30
+ "typecheck": ("typecheck", "type check", "type-check", "tsc", "mypy", "pyright"),
31
+ "build": ("build",),
32
+ "doctor": ("doctor",),
33
+ "release": ("release", "deploy", "deployment", "publish"),
34
+ }
35
+
36
+ CLAIM_PATTERNS = {
37
+ "test": re.compile(r"\b(test|tests|pytest|vitest|jest)\b[^.\n]*(pass|passed|passing|green|succeed|succeeded)", re.I),
38
+ "lint": re.compile(r"\b(lint|eslint|ruff|flake8)\b[^.\n]*(pass|passed|passing|clean|succeed|succeeded)", re.I),
39
+ "typecheck": re.compile(
40
+ r"\b(typecheck|type check|type-check|tsc|mypy|pyright)\b[^.\n]*(pass|passed|passing|clean|succeed|succeeded)",
41
+ re.I,
42
+ ),
43
+ "build": re.compile(r"\b(build|built)\b[^.\n]*(pass|passed|passing|succeed|succeeded|successful|completed)", re.I),
44
+ "doctor": re.compile(r"\bdoctor\b[^.\n]*(pass|passed|passing|ok|healthy|clean|succeed|succeeded)", re.I),
45
+ "release": re.compile(
46
+ r"\b(release|deploy|deployment|publish)\b[^.\n]*(pass|passed|passing|succeed|succeeded|successful|completed)",
47
+ re.I,
48
+ ),
49
+ }
50
+
51
+ NEGATED_CLAIM_RE = re.compile(r"\b(not run|did not run|didn't run|was not run|wasn't run|without running)\b", re.I)
52
+ _MISSING = object()
53
+
54
+
55
+ def audit_session(
56
+ root: str | Path,
57
+ session_id: str | None = None,
58
+ *,
59
+ strict: bool = False,
60
+ finishing: bool = False,
61
+ run_health_check: bool = True,
62
+ rebuild: bool = True,
63
+ summary: dict[str, Any] | None = None,
64
+ evidence: list[dict[str, Any]] | None = None,
65
+ latest_pack: dict[str, Any] | None | object = _MISSING,
66
+ context_audit: dict[str, Any] | None | object = _MISSING,
67
+ doctor: dict[str, Any] | None | object = _MISSING,
68
+ ) -> dict[str, Any]:
69
+ if summary is None:
70
+ summary = summarize_session(root, session_id, rebuild=rebuild)
71
+ rebuild = False
72
+ resolved = summary["session_id"]
73
+ if evidence is None:
74
+ evidence = evidence_rows(root, resolved, rebuild=rebuild)
75
+ rebuild = False
76
+ event_counts = summary.get("event_counts") or {}
77
+ if latest_pack is _MISSING:
78
+ latest_pack = _latest_context_pack(root, resolved, rebuild=rebuild)
79
+ rebuild = False
80
+ if context_audit is _MISSING:
81
+ context_audit = _safe_context_audit(root, latest_pack, rebuild=rebuild)
82
+ if doctor is _MISSING:
83
+ doctor = run_doctor(root).as_dict() if run_health_check else None
84
+ current = read_current_session(root)
85
+ is_current = bool(current and current.session_id == resolved and current.status == "active")
86
+ failed_evidence = [row for row in evidence if row.get("event_type") == "tool.result" and row.get("tool_exit_code") not in (None, 0)]
87
+ dirty_status = git_status_short(root)
88
+ checks = [
89
+ _check(
90
+ "session_started",
91
+ CHECK_PASS if event_counts.get("session.started") or event_counts.get("work.started") else CHECK_FAIL,
92
+ "session has a start event" if event_counts.get("session.started") or event_counts.get("work.started") else "session has no start event",
93
+ ),
94
+ _check(
95
+ "session_finished",
96
+ CHECK_PASS if event_counts.get("session.ended") or event_counts.get("work.finished") or finishing else CHECK_WARN,
97
+ "session has a finish event" if event_counts.get("session.ended") or event_counts.get("work.finished") else (
98
+ "work finish is in progress" if finishing else "session is still active" if is_current else "session has no finish event"
99
+ ),
100
+ ),
101
+ _check(
102
+ "evidence_present",
103
+ CHECK_PASS if evidence else CHECK_WARN,
104
+ f"{len(evidence)} evidence record(s) captured" if evidence else "no evidence captured",
105
+ ),
106
+ _check(
107
+ "failed_tool_results",
108
+ CHECK_FAIL if failed_evidence else CHECK_PASS,
109
+ f"{len(failed_evidence)} failed tool result(s)" if failed_evidence else "no failed tool results",
110
+ ),
111
+ _check(
112
+ "context_pack_created",
113
+ CHECK_PASS if latest_pack else CHECK_WARN,
114
+ f"context pack {latest_pack['pack_id']} recorded" if latest_pack else "no context pack recorded",
115
+ ),
116
+ _context_check("context_sources_consumed", context_audit, "consumed_count", latest_pack),
117
+ _context_check("context_sources_cited", context_audit, "cited_count", latest_pack),
118
+ _doctor_check(doctor),
119
+ _check(
120
+ "git_dirty",
121
+ CHECK_WARN if dirty_status else CHECK_PASS,
122
+ "git worktree has changes" if dirty_status else "git worktree is clean",
123
+ ),
124
+ ]
125
+ return {
126
+ "session_id": resolved,
127
+ "ok": not any(check["status"] == CHECK_FAIL for check in checks),
128
+ "strict": strict,
129
+ "summary": summary,
130
+ "checks": checks,
131
+ }
132
+
133
+
134
+ def audit_claims(
135
+ root: str | Path,
136
+ text: str,
137
+ session_id: str | None = None,
138
+ *,
139
+ strict: bool = False,
140
+ rebuild: bool = True,
141
+ summary: dict[str, Any] | None = None,
142
+ evidence: list[dict[str, Any]] | None = None,
143
+ ) -> dict[str, Any]:
144
+ if summary is None:
145
+ summary = summarize_session(root, session_id, rebuild=rebuild)
146
+ rebuild = False
147
+ resolved = summary["session_id"]
148
+ if evidence is None:
149
+ evidence = evidence_rows(root, resolved, rebuild=rebuild)
150
+ tool_results = [row for row in evidence if row.get("event_type") == "tool.result"]
151
+ claims = []
152
+ for family in _detect_claim_families(text):
153
+ evidence = _latest_relevant_tool_result(tool_results, family)
154
+ if evidence is None:
155
+ status = CLAIM_UNSUPPORTED
156
+ message = f"no {family} evidence found"
157
+ elif evidence.get("tool_exit_code") == 0:
158
+ status = CLAIM_SUPPORTED
159
+ message = f"latest {family} evidence succeeded"
160
+ else:
161
+ status = CLAIM_CONTRADICTED
162
+ message = f"latest {family} evidence failed with exit {evidence.get('tool_exit_code')}"
163
+ claims.append(
164
+ {
165
+ "family": family,
166
+ "status": status,
167
+ "message": message,
168
+ "evidence": _evidence_ref(evidence),
169
+ }
170
+ )
171
+ return {
172
+ "session_id": resolved,
173
+ "ok": not any(claim["status"] in {CLAIM_UNSUPPORTED, CLAIM_CONTRADICTED} for claim in claims),
174
+ "strict": strict,
175
+ "claims": claims,
176
+ }
177
+
178
+
179
+ def format_session_audit(audit: dict[str, Any]) -> str:
180
+ lines = [f"session={audit['session_id']}", f"ok={str(audit['ok']).lower()}"]
181
+ for check in audit["checks"]:
182
+ lines.append(f"{check['id']}={check['status']} {check['message']}")
183
+ return "\n".join(lines)
184
+
185
+
186
+ def format_claims_audit(audit: dict[str, Any]) -> str:
187
+ lines = [f"session={audit['session_id']}", f"ok={str(audit['ok']).lower()}"]
188
+ if not audit["claims"]:
189
+ lines.append("claims=none")
190
+ return "\n".join(lines)
191
+ for claim in audit["claims"]:
192
+ evidence = claim.get("evidence") or {}
193
+ evidence_path = evidence.get("file_path") or ""
194
+ lines.append(f"{claim['family']}={claim['status']} {claim['message']} {evidence_path}".rstrip())
195
+ return "\n".join(lines)
196
+
197
+
198
+ def session_audit_gaps(audit: dict[str, Any]) -> list[str]:
199
+ gaps: list[str] = []
200
+ for check in audit.get("checks") or []:
201
+ if check.get("id") == "git_dirty":
202
+ continue
203
+ if check.get("status") in {CHECK_WARN, CHECK_FAIL}:
204
+ gaps.append(f"{check['id']}: {check['message']}")
205
+ return gaps
206
+
207
+
208
+ def claims_audit_gaps(audit: dict[str, Any] | None) -> list[str]:
209
+ if not audit:
210
+ return []
211
+ gaps: list[str] = []
212
+ for claim in audit.get("claims") or []:
213
+ if claim.get("status") in {CLAIM_UNSUPPORTED, CLAIM_CONTRADICTED}:
214
+ gaps.append(f"{claim['family']}: {claim['message']}")
215
+ return gaps
216
+
217
+
218
+ def strict_session_exit_code(audit: dict[str, Any]) -> int:
219
+ return 1 if any(check["status"] == CHECK_FAIL for check in audit.get("checks") or []) else 0
220
+
221
+
222
+ def strict_claims_exit_code(audit: dict[str, Any]) -> int:
223
+ return 1 if any(claim["status"] in {CLAIM_UNSUPPORTED, CLAIM_CONTRADICTED} for claim in audit.get("claims") or []) else 0
224
+
225
+
226
+ def _check(
227
+ check_id: str,
228
+ status: str,
229
+ message: str,
230
+ *,
231
+ details: dict[str, Any] | None = None,
232
+ ) -> dict[str, Any]:
233
+ if status not in CHECK_STATUSES:
234
+ raise AgentDirError(f"Unknown audit check status: {status}")
235
+ payload = {"id": check_id, "status": status, "message": message}
236
+ if details:
237
+ payload["details"] = details
238
+ return payload
239
+
240
+
241
+ def _context_check(
242
+ check_id: str,
243
+ context_audit: dict[str, Any] | None,
244
+ count_key: str,
245
+ latest_pack: dict[str, Any] | None,
246
+ ) -> dict[str, Any]:
247
+ if latest_pack is None:
248
+ return _check(check_id, CHECK_NOT_APPLICABLE, "no context pack recorded")
249
+ if context_audit is None:
250
+ return _check(check_id, CHECK_WARN, "context audit unavailable")
251
+ if context_audit.get("error"):
252
+ return _check(check_id, CHECK_WARN, f"context audit failed: {context_audit['error']}")
253
+ count = int(context_audit.get(count_key) or 0)
254
+ action = "consumed" if count_key == "consumed_count" else "cited"
255
+ return _check(
256
+ check_id,
257
+ CHECK_PASS if count else CHECK_WARN,
258
+ f"{count} context source(s) {action}" if count else f"no context sources {action}",
259
+ )
260
+
261
+
262
+ def _doctor_check(doctor: dict[str, Any] | None) -> dict[str, Any]:
263
+ if doctor is None:
264
+ return _check("doctor_ok", CHECK_NOT_APPLICABLE, "doctor was not run")
265
+ return _check(
266
+ "doctor_ok",
267
+ CHECK_PASS if doctor.get("ok") else CHECK_FAIL,
268
+ "doctor reported ok" if doctor.get("ok") else "doctor reported errors",
269
+ details={"doctor": doctor},
270
+ )
271
+
272
+
273
+ def _latest_context_pack(root: str | Path, session_id: str, *, rebuild: bool = True) -> dict[str, Any] | None:
274
+ from .control import latest_context_pack
275
+
276
+ return latest_context_pack(root, session_id, rebuild=rebuild)
277
+
278
+
279
+ def _safe_context_audit(root: str | Path, latest_pack: dict[str, Any] | None | object, *, rebuild: bool = True) -> dict[str, Any] | None:
280
+ if latest_pack is _MISSING or latest_pack is None:
281
+ return None
282
+ pack_id = latest_pack["pack_id"]
283
+ try:
284
+ return audit_context_pack(root, pack_id, rebuild=rebuild)
285
+ except AgentDirError as exc:
286
+ return {"error": str(exc), "pack_id": pack_id}
287
+
288
+
289
+ def _detect_claim_families(text: str) -> list[str]:
290
+ families: list[str] = []
291
+ for family in CLAIM_FAMILIES:
292
+ pattern = CLAIM_PATTERNS[family]
293
+ for match in pattern.finditer(text):
294
+ window = text[max(0, match.start() - 40): match.end() + 40]
295
+ if NEGATED_CLAIM_RE.search(window):
296
+ continue
297
+ families.append(family)
298
+ break
299
+ return families
300
+
301
+
302
+ def _latest_relevant_tool_result(rows: list[dict[str, Any]], family: str) -> dict[str, Any] | None:
303
+ for row in reversed(rows):
304
+ if row.get("family") == family:
305
+ return row
306
+ keywords = CLAIM_KEYWORDS[family]
307
+ for row in reversed(rows):
308
+ haystack = _tool_result_search_text(row)
309
+ if any(keyword in haystack for keyword in keywords):
310
+ return row
311
+ return None
312
+
313
+
314
+ def _tool_result_search_text(row: dict[str, Any]) -> str:
315
+ body_lines = []
316
+ for line in str(row.get("body_text") or "").splitlines():
317
+ if line.startswith(("cwd=", "duration_ms=", "redactions=", "stdout_truncated=", "stderr_truncated=")):
318
+ continue
319
+ body_lines.append(line)
320
+ return " ".join(
321
+ [
322
+ str(row.get("tool") or ""),
323
+ str(row.get("subject") or ""),
324
+ "\n".join(body_lines),
325
+ ]
326
+ ).lower()
327
+
328
+
329
+ def _evidence_ref(row: dict[str, Any] | None) -> dict[str, Any] | None:
330
+ if row is None:
331
+ return None
332
+ return {
333
+ "event_type": row.get("event_type"),
334
+ "tool": row.get("tool"),
335
+ "tool_exit_code": row.get("tool_exit_code"),
336
+ "subject": row.get("subject"),
337
+ "file_path": row.get("file_path"),
338
+ "date_utc": row.get("date_utc"),
339
+ }
agentdir/capture.py ADDED
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from .events import emit_event
13
+ from .git import git_head, workspace_name
14
+ from .redaction import redact_text
15
+ from .sessions import ensure_session
16
+ from .store import AgentDirError
17
+
18
+ DEFAULT_MAX_CAPTURE_BYTES = 256 * 1024
19
+
20
+
21
+ @dataclass
22
+ class CapturedText:
23
+ text: str = ""
24
+ truncated: bool = False
25
+
26
+
27
+ def _append_limited(capture: CapturedText, chunk: str, max_bytes: int) -> None:
28
+ if capture.truncated:
29
+ return
30
+ current = len(capture.text.encode("utf-8", errors="replace"))
31
+ remaining = max_bytes - current
32
+ encoded = chunk.encode("utf-8", errors="replace")
33
+ if len(encoded) <= remaining:
34
+ capture.text += chunk
35
+ return
36
+ capture.text += encoded[: max(0, remaining)].decode("utf-8", errors="replace")
37
+ capture.truncated = True
38
+
39
+
40
+ def _tee_stream(stream, target, capture: CapturedText, max_bytes: int) -> None:
41
+ try:
42
+ for chunk in iter(lambda: stream.readline(), ""):
43
+ if chunk == "":
44
+ break
45
+ target.write(chunk)
46
+ target.flush()
47
+ _append_limited(capture, chunk, max_bytes)
48
+ finally:
49
+ stream.close()
50
+
51
+
52
+ def run_tool(
53
+ root: str | Path,
54
+ *,
55
+ argv: list[str],
56
+ session_id: str | None = None,
57
+ tool_name: str | None = None,
58
+ cwd: str | Path | None = None,
59
+ max_capture_bytes: int = DEFAULT_MAX_CAPTURE_BYTES,
60
+ redact: bool = True,
61
+ ) -> int:
62
+ if not argv:
63
+ raise AgentDirError("argv is required")
64
+ if max_capture_bytes < 0:
65
+ raise AgentDirError("max_capture_bytes must be non-negative")
66
+
67
+ cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
68
+ session = ensure_session(root, session_id, title=f"AgentDir run: {argv[0]}", emit_started=session_id is None)
69
+ tool = tool_name or Path(argv[0]).name
70
+ start = datetime.now(UTC)
71
+ command_text = " ".join(argv)
72
+ head = git_head(cwd_path)
73
+ workspace = workspace_name(cwd_path)
74
+
75
+ emit_event(
76
+ root,
77
+ session_id=session.session_id,
78
+ event_type="tool.call",
79
+ subject=f"tool.call {tool}",
80
+ from_actor="agent",
81
+ body="\n".join(
82
+ [
83
+ f"tool={tool}",
84
+ f"argv={argv!r}",
85
+ f"cwd={cwd_path}",
86
+ f"started_at={start.isoformat()}",
87
+ ]
88
+ ),
89
+ workspace=workspace,
90
+ git_head=head,
91
+ tool=tool,
92
+ )
93
+
94
+ stdout_capture = CapturedText()
95
+ stderr_capture = CapturedText()
96
+ env = os.environ.copy()
97
+ env["AGENTDIR_SESSION"] = session.session_id
98
+ started = time.monotonic()
99
+ try:
100
+ process = subprocess.Popen(
101
+ argv,
102
+ cwd=cwd_path,
103
+ env=env,
104
+ text=True,
105
+ stdout=subprocess.PIPE,
106
+ stderr=subprocess.PIPE,
107
+ errors="replace",
108
+ )
109
+ except FileNotFoundError:
110
+ message = f"agentdir: command not found: {argv[0]}\n"
111
+ sys.stderr.write(message)
112
+ sys.stderr.flush()
113
+ emit_event(
114
+ root,
115
+ session_id=session.session_id,
116
+ event_type="tool.result",
117
+ subject=f"tool.result {tool} exit 127",
118
+ from_actor="agent",
119
+ body=f"command={command_text}\nexit_code=127\nstderr:\n{message}",
120
+ workspace=workspace,
121
+ git_head=head,
122
+ tool=tool,
123
+ tool_exit_code=127,
124
+ )
125
+ return 127
126
+
127
+ assert process.stdout is not None
128
+ assert process.stderr is not None
129
+ stdout_thread = threading.Thread(
130
+ target=_tee_stream,
131
+ args=(process.stdout, sys.stdout, stdout_capture, max_capture_bytes),
132
+ daemon=True,
133
+ )
134
+ stderr_thread = threading.Thread(
135
+ target=_tee_stream,
136
+ args=(process.stderr, sys.stderr, stderr_capture, max_capture_bytes),
137
+ daemon=True,
138
+ )
139
+ stdout_thread.start()
140
+ stderr_thread.start()
141
+ exit_code = process.wait()
142
+ stdout_thread.join()
143
+ stderr_thread.join()
144
+ duration_ms = int((time.monotonic() - started) * 1000)
145
+
146
+ stored_stdout = redact_text(stdout_capture.text) if redact else None
147
+ stored_stderr = redact_text(stderr_capture.text) if redact else None
148
+ stdout_text = stored_stdout.text if stored_stdout else stdout_capture.text
149
+ stderr_text = stored_stderr.text if stored_stderr else stderr_capture.text
150
+ redactions = (stored_stdout.replacements if stored_stdout else 0) + (
151
+ stored_stderr.replacements if stored_stderr else 0
152
+ )
153
+
154
+ body = "\n".join(
155
+ [
156
+ f"command={command_text}",
157
+ f"exit_code={exit_code}",
158
+ f"duration_ms={duration_ms}",
159
+ f"cwd={cwd_path}",
160
+ f"redactions={redactions}",
161
+ f"stdout_truncated={str(stdout_capture.truncated).lower()}",
162
+ f"stderr_truncated={str(stderr_capture.truncated).lower()}",
163
+ "",
164
+ "stdout:",
165
+ stdout_text,
166
+ "",
167
+ "stderr:",
168
+ stderr_text,
169
+ ]
170
+ )
171
+ emit_event(
172
+ root,
173
+ session_id=session.session_id,
174
+ event_type="tool.result",
175
+ subject=f"tool.result {tool} exit {exit_code}",
176
+ from_actor="agent",
177
+ body=body,
178
+ workspace=workspace,
179
+ git_head=head,
180
+ tool=tool,
181
+ tool_exit_code=exit_code,
182
+ extra_headers={
183
+ "X-AgentDir-Duration-Ms": str(duration_ms),
184
+ "X-AgentDir-Redactions": str(redactions),
185
+ },
186
+ )
187
+ return int(exit_code)