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 +3 -0
- agentdir/__main__.py +6 -0
- agentdir/actors.py +50 -0
- agentdir/artifacts.py +56 -0
- agentdir/audit.py +339 -0
- agentdir/capture.py +187 -0
- agentdir/cli.py +1900 -0
- agentdir/context.py +654 -0
- agentdir/control.py +729 -0
- agentdir/daemon.py +253 -0
- agentdir/doctor.py +115 -0
- agentdir/envelope.py +147 -0
- agentdir/events.py +72 -0
- agentdir/federation.py +530 -0
- agentdir/git.py +45 -0
- agentdir/gitignore.py +106 -0
- agentdir/hooks.py +215 -0
- agentdir/index.py +362 -0
- agentdir/mailbox.py +84 -0
- agentdir/memory.py +1274 -0
- agentdir/query.py +65 -0
- agentdir/redaction.py +42 -0
- agentdir/rendering.py +89 -0
- agentdir/replay.py +31 -0
- agentdir/retention.py +319 -0
- agentdir/review.py +288 -0
- agentdir/secrets.py +158 -0
- agentdir/sessions.py +171 -0
- agentdir/skills.py +685 -0
- agentdir/store.py +270 -0
- agentdir/upgrade.py +252 -0
- agentdir_cli-0.7.5.dist-info/METADATA +393 -0
- agentdir_cli-0.7.5.dist-info/RECORD +37 -0
- agentdir_cli-0.7.5.dist-info/WHEEL +5 -0
- agentdir_cli-0.7.5.dist-info/entry_points.txt +2 -0
- agentdir_cli-0.7.5.dist-info/licenses/LICENSE +21 -0
- agentdir_cli-0.7.5.dist-info/top_level.txt +1 -0
agentdir/__init__.py
ADDED
agentdir/__main__.py
ADDED
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)
|