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/daemon.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Event
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .federation import rebuild_registered_roots
|
|
15
|
+
from .index import rebuild_index
|
|
16
|
+
from .store import AgentDirError, init_root, paths_for, require_root
|
|
17
|
+
|
|
18
|
+
DAEMON_STATE_FILE = "memory-daemon.json"
|
|
19
|
+
DAEMON_STOP_FILE = "memory-daemon.stop"
|
|
20
|
+
DAEMON_LOG_FILE = "memory-daemon.log"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def start_memory_daemon(
|
|
24
|
+
root: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
interval: float = 2.0,
|
|
27
|
+
group: str | None = None,
|
|
28
|
+
force: bool = False,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
paths = init_root(root)
|
|
31
|
+
current = memory_daemon_status(paths.root)
|
|
32
|
+
if current["running"] and not force:
|
|
33
|
+
return {**current, "started": False}
|
|
34
|
+
_stop_path(paths.root).unlink(missing_ok=True)
|
|
35
|
+
log_path = paths.state / DAEMON_LOG_FILE
|
|
36
|
+
command = [
|
|
37
|
+
sys.executable,
|
|
38
|
+
"-m",
|
|
39
|
+
"agentdir",
|
|
40
|
+
"memory",
|
|
41
|
+
"daemon",
|
|
42
|
+
"run",
|
|
43
|
+
"--root",
|
|
44
|
+
str(paths.root),
|
|
45
|
+
"--interval",
|
|
46
|
+
str(interval),
|
|
47
|
+
]
|
|
48
|
+
if group:
|
|
49
|
+
command.extend(["--group", group])
|
|
50
|
+
log = log_path.open("ab")
|
|
51
|
+
process = subprocess.Popen(
|
|
52
|
+
command,
|
|
53
|
+
stdout=log,
|
|
54
|
+
stderr=subprocess.STDOUT,
|
|
55
|
+
cwd=Path.cwd(),
|
|
56
|
+
env=os.environ.copy(),
|
|
57
|
+
start_new_session=True,
|
|
58
|
+
)
|
|
59
|
+
state = _daemon_state(paths.root, pid=process.pid, interval=interval, group=group)
|
|
60
|
+
state.update({"started": True, "log_path": str(log_path)})
|
|
61
|
+
_write_state(paths.root, state)
|
|
62
|
+
return memory_daemon_status(paths.root) | {"started": True}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_memory_daemon(
|
|
66
|
+
root: str | Path,
|
|
67
|
+
*,
|
|
68
|
+
interval: float = 2.0,
|
|
69
|
+
group: str | None = None,
|
|
70
|
+
once: bool = False,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
paths = init_root(root)
|
|
73
|
+
state = _daemon_state(paths.root, pid=os.getpid(), interval=interval, group=group)
|
|
74
|
+
_write_state(paths.root, state)
|
|
75
|
+
_refresh(paths.root, group=group)
|
|
76
|
+
if once:
|
|
77
|
+
return memory_daemon_status(paths.root)
|
|
78
|
+
if _watchfiles_available():
|
|
79
|
+
_watch_loop(paths.root, group=group, interval=interval)
|
|
80
|
+
else:
|
|
81
|
+
_poll_loop(paths.root, group=group, interval=interval)
|
|
82
|
+
return memory_daemon_status(paths.root)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stop_memory_daemon(root: str | Path, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
86
|
+
paths = require_root(root)
|
|
87
|
+
_stop_path(paths.root).write_text(now_iso() + "\n", encoding="utf-8")
|
|
88
|
+
status = memory_daemon_status(paths.root)
|
|
89
|
+
pid = status.get("pid")
|
|
90
|
+
if pid and status["running"]:
|
|
91
|
+
try:
|
|
92
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
deadline = time.monotonic() + timeout
|
|
96
|
+
while time.monotonic() < deadline:
|
|
97
|
+
if not _pid_running(int(pid)):
|
|
98
|
+
break
|
|
99
|
+
time.sleep(0.05)
|
|
100
|
+
return memory_daemon_status(paths.root)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def memory_daemon_status(root: str | Path) -> dict[str, Any]:
|
|
104
|
+
paths = require_root(root)
|
|
105
|
+
state = _read_state(paths.root)
|
|
106
|
+
pid = state.get("pid")
|
|
107
|
+
running = _pid_running(int(pid)) if pid else False
|
|
108
|
+
return {
|
|
109
|
+
**state,
|
|
110
|
+
"running": running,
|
|
111
|
+
"stop_requested": _stop_path(paths.root).is_file(),
|
|
112
|
+
"watch_backend": "watchfiles" if _watchfiles_available() else "poll",
|
|
113
|
+
"state_path": str(_state_path(paths.root)),
|
|
114
|
+
"log_path": str(paths.state / DAEMON_LOG_FILE),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_memory_daemon_status(status: dict[str, Any]) -> str:
|
|
119
|
+
lines = [
|
|
120
|
+
f"running={str(status['running']).lower()}",
|
|
121
|
+
f"pid={status.get('pid') or ''}",
|
|
122
|
+
f"backend={status['watch_backend']}",
|
|
123
|
+
f"interval={status.get('interval') or ''}",
|
|
124
|
+
f"group={status.get('group') or ''}",
|
|
125
|
+
f"last_refresh_at={status.get('last_refresh_at') or ''}",
|
|
126
|
+
f"last_refresh_ok={str(status.get('last_refresh_ok', False)).lower()}",
|
|
127
|
+
]
|
|
128
|
+
if status.get("last_error"):
|
|
129
|
+
lines.append(f"last_error={status['last_error']}")
|
|
130
|
+
return "\n".join(lines) + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def now_iso() -> str:
|
|
134
|
+
return datetime.now(UTC).isoformat()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _poll_loop(root: Path, *, group: str | None, interval: float) -> None:
|
|
138
|
+
while not _stop_path(root).is_file():
|
|
139
|
+
time.sleep(max(interval, 0.1))
|
|
140
|
+
if _stop_path(root).is_file():
|
|
141
|
+
break
|
|
142
|
+
_refresh(root, group=group)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _watch_loop(root: Path, *, group: str | None, interval: float) -> None:
|
|
146
|
+
try:
|
|
147
|
+
from watchfiles import watch
|
|
148
|
+
except ImportError:
|
|
149
|
+
_poll_loop(root, group=group, interval=interval)
|
|
150
|
+
return
|
|
151
|
+
stop = Event()
|
|
152
|
+
watch_paths = _watch_paths(root)
|
|
153
|
+
timeout_ms = max(int(interval * 1000), 100)
|
|
154
|
+
for changes in watch(
|
|
155
|
+
*watch_paths,
|
|
156
|
+
stop_event=stop,
|
|
157
|
+
yield_on_timeout=True,
|
|
158
|
+
rust_timeout=timeout_ms,
|
|
159
|
+
recursive=True,
|
|
160
|
+
):
|
|
161
|
+
if _stop_path(root).is_file():
|
|
162
|
+
stop.set()
|
|
163
|
+
break
|
|
164
|
+
if changes:
|
|
165
|
+
_refresh(root, group=group)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _refresh(root: Path, *, group: str | None) -> None:
|
|
169
|
+
state = _read_state(root)
|
|
170
|
+
try:
|
|
171
|
+
result = rebuild_index(root)
|
|
172
|
+
roots = rebuild_registered_roots(root, group=group)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
state.update(
|
|
175
|
+
{
|
|
176
|
+
"last_refresh_at": now_iso(),
|
|
177
|
+
"last_refresh_ok": False,
|
|
178
|
+
"last_error": str(exc),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
state.update(
|
|
183
|
+
{
|
|
184
|
+
"last_refresh_at": now_iso(),
|
|
185
|
+
"last_refresh_ok": True,
|
|
186
|
+
"last_error": None,
|
|
187
|
+
"indexed": result.indexed,
|
|
188
|
+
"malformed": result.malformed,
|
|
189
|
+
"registered_roots": len(roots),
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
_write_state(root, state)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _watch_paths(root: Path) -> list[str]:
|
|
196
|
+
paths = paths_for(root)
|
|
197
|
+
candidates = [paths.sessions, paths.actors, paths.queues, paths.state]
|
|
198
|
+
return [str(path) for path in candidates if path.exists()]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _daemon_state(root: Path, *, pid: int, interval: float, group: str | None) -> dict[str, Any]:
|
|
202
|
+
return {
|
|
203
|
+
"pid": pid,
|
|
204
|
+
"root": str(root),
|
|
205
|
+
"interval": interval,
|
|
206
|
+
"group": group,
|
|
207
|
+
"started_at": now_iso(),
|
|
208
|
+
"last_refresh_at": None,
|
|
209
|
+
"last_refresh_ok": False,
|
|
210
|
+
"last_error": None,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _read_state(root: Path) -> dict[str, Any]:
|
|
215
|
+
path = _state_path(root)
|
|
216
|
+
if not path.is_file():
|
|
217
|
+
return {}
|
|
218
|
+
try:
|
|
219
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
220
|
+
except json.JSONDecodeError as exc:
|
|
221
|
+
raise AgentDirError(f"Invalid daemon state file: {exc}") from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _write_state(root: Path, state: dict[str, Any]) -> None:
|
|
225
|
+
path = _state_path(root)
|
|
226
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _state_path(root: Path) -> Path:
|
|
231
|
+
return paths_for(root).state / DAEMON_STATE_FILE
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _stop_path(root: Path) -> Path:
|
|
235
|
+
return paths_for(root).state / DAEMON_STOP_FILE
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _pid_running(pid: int) -> bool:
|
|
239
|
+
if pid <= 0:
|
|
240
|
+
return False
|
|
241
|
+
try:
|
|
242
|
+
os.kill(pid, 0)
|
|
243
|
+
except OSError:
|
|
244
|
+
return False
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _watchfiles_available() -> bool:
|
|
249
|
+
try:
|
|
250
|
+
import watchfiles # noqa: F401
|
|
251
|
+
except ImportError:
|
|
252
|
+
return False
|
|
253
|
+
return True
|
agentdir/doctor.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .artifacts import artifact_path
|
|
10
|
+
from .envelope import parse_envelope, validate_required
|
|
11
|
+
from .mailbox import iter_records
|
|
12
|
+
from .redaction import looks_secret_bearing
|
|
13
|
+
from .secrets import scan_secret_records
|
|
14
|
+
from .store import discover_mailboxes, require_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DoctorReport:
|
|
19
|
+
ok: bool = True
|
|
20
|
+
errors: list[str] = field(default_factory=list)
|
|
21
|
+
warnings: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
def add_error(self, message: str) -> None:
|
|
24
|
+
self.ok = False
|
|
25
|
+
self.errors.append(message)
|
|
26
|
+
|
|
27
|
+
def add_warning(self, message: str) -> None:
|
|
28
|
+
self.warnings.append(message)
|
|
29
|
+
|
|
30
|
+
def as_dict(self) -> dict[str, object]:
|
|
31
|
+
return {"ok": self.ok, "errors": self.errors, "warnings": self.warnings}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_doctor(root: str | Path) -> DoctorReport:
|
|
35
|
+
report = DoctorReport()
|
|
36
|
+
try:
|
|
37
|
+
paths = require_root(root)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
report.add_error(str(exc))
|
|
40
|
+
return report
|
|
41
|
+
|
|
42
|
+
for required in (paths.meta, paths.sessions, paths.actors, paths.artifacts, paths.indexes):
|
|
43
|
+
if not required.exists():
|
|
44
|
+
report.add_error(f"missing required path: {required}")
|
|
45
|
+
|
|
46
|
+
seen: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
47
|
+
for _kind, mailbox in discover_mailboxes(root):
|
|
48
|
+
for record in iter_records(mailbox):
|
|
49
|
+
try:
|
|
50
|
+
parsed = parse_envelope(record.path)
|
|
51
|
+
missing = validate_required(parsed)
|
|
52
|
+
if missing:
|
|
53
|
+
report.add_error(f"{record.path}: missing headers {', '.join(missing)}")
|
|
54
|
+
relative_path = str(record.path.relative_to(paths.root))
|
|
55
|
+
if parsed.message_id:
|
|
56
|
+
seen[parsed.message_id].append((relative_path, parsed.body_sha256))
|
|
57
|
+
for sha in parsed.message.get_all("X-AgentDir-Blob-SHA256", []):
|
|
58
|
+
if not artifact_path(root, sha).exists():
|
|
59
|
+
report.add_error(f"{record.path}: missing artifact blob {sha}")
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
report.add_error(f"{record.path}: parse error: {exc}")
|
|
62
|
+
|
|
63
|
+
for message_id, file_hashes in seen.items():
|
|
64
|
+
if len(file_hashes) > 1:
|
|
65
|
+
files = [path for path, _hash in file_hashes]
|
|
66
|
+
body_hashes = {_hash for _path, _hash in file_hashes}
|
|
67
|
+
if len(body_hashes) == 1:
|
|
68
|
+
report.add_warning(f"replicated Message-ID {message_id}: {json.dumps(files)}")
|
|
69
|
+
else:
|
|
70
|
+
report.add_error(f"conflicting duplicate Message-ID {message_id}: {json.dumps(files)}")
|
|
71
|
+
|
|
72
|
+
for tmp in paths.root.glob("**/Maildir/tmp/*"):
|
|
73
|
+
if tmp.is_file():
|
|
74
|
+
report.add_warning(f"incomplete tmp record ignored: {tmp.relative_to(paths.root)}")
|
|
75
|
+
secret_findings = scan_secret_records(paths.root)
|
|
76
|
+
for finding in secret_findings:
|
|
77
|
+
labels = ",".join(finding.labels)
|
|
78
|
+
report.add_error(
|
|
79
|
+
f"{paths.root / finding.path}: body contains secret-like text "
|
|
80
|
+
f"labels={labels}; run 'agentdir secrets redact --apply'"
|
|
81
|
+
)
|
|
82
|
+
if not secret_findings:
|
|
83
|
+
for finding in _index_secret_findings(paths.index_path):
|
|
84
|
+
report.add_error(
|
|
85
|
+
f"{paths.index_path}: {finding} contains secret-like indexed text; "
|
|
86
|
+
"run 'agentdir index rebuild'"
|
|
87
|
+
)
|
|
88
|
+
return report
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _index_secret_findings(index_path: Path) -> list[str]:
|
|
92
|
+
if not index_path.is_file():
|
|
93
|
+
return []
|
|
94
|
+
findings: list[str] = []
|
|
95
|
+
tables = (
|
|
96
|
+
("messages", "id", "body_text"),
|
|
97
|
+
("memory_documents", "id", "body_text"),
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with sqlite3.connect(index_path) as conn:
|
|
101
|
+
for table, id_column, body_column in tables:
|
|
102
|
+
if not _table_exists(conn, table):
|
|
103
|
+
continue
|
|
104
|
+
for row_id, body_text in conn.execute(f"select {id_column}, {body_column} from {table}"):
|
|
105
|
+
if looks_secret_bearing(body_text or ""):
|
|
106
|
+
findings.append(f"{table}.{id_column}={row_id}")
|
|
107
|
+
break
|
|
108
|
+
except sqlite3.DatabaseError as exc:
|
|
109
|
+
findings.append(f"index scan failed: {exc}")
|
|
110
|
+
return findings
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
114
|
+
row = conn.execute("select 1 from sqlite_master where type = 'table' and name = ?", (table,)).fetchone()
|
|
115
|
+
return row is not None
|
agentdir/envelope.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email import policy
|
|
8
|
+
from email.message import EmailMessage, Message
|
|
9
|
+
from email.parser import BytesParser
|
|
10
|
+
from email.utils import format_datetime, make_msgid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from .store import AgentDirError
|
|
15
|
+
|
|
16
|
+
AGENTDIR_VERSION = "0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ParsedEnvelope:
|
|
21
|
+
message: Message
|
|
22
|
+
raw: bytes
|
|
23
|
+
path: Path
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def message_id(self) -> str | None:
|
|
27
|
+
return self.message.get("Message-ID")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def body_text(self) -> str:
|
|
31
|
+
if self.message.is_multipart():
|
|
32
|
+
parts: list[str] = []
|
|
33
|
+
for part in self.message.walk():
|
|
34
|
+
if part.get_content_maintype() == "text":
|
|
35
|
+
try:
|
|
36
|
+
parts.append(part.get_content())
|
|
37
|
+
except Exception:
|
|
38
|
+
continue
|
|
39
|
+
return "\n".join(parts)
|
|
40
|
+
try:
|
|
41
|
+
content = self.message.get_content()
|
|
42
|
+
except Exception:
|
|
43
|
+
payload = self.message.get_payload(decode=True)
|
|
44
|
+
if payload is None:
|
|
45
|
+
return ""
|
|
46
|
+
return payload.decode("utf-8", errors="replace")
|
|
47
|
+
return content if isinstance(content, str) else str(content)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def body_sha256(self) -> str:
|
|
51
|
+
return hashlib.sha256(self.body_text.encode("utf-8", errors="replace")).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def actor_address(actor: str | None) -> str:
|
|
55
|
+
local = (actor or "agentdir").strip() or "agentdir"
|
|
56
|
+
if "@" in local:
|
|
57
|
+
return local
|
|
58
|
+
safe = "".join(ch if ch.isalnum() or ch in "._+-" else "-" for ch in local)
|
|
59
|
+
return f"{safe}@agentdir.local"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_envelope(
|
|
63
|
+
*,
|
|
64
|
+
event_type: str,
|
|
65
|
+
body: str,
|
|
66
|
+
subject: str | None = None,
|
|
67
|
+
from_actor: str | None = None,
|
|
68
|
+
to_actor: str | None = None,
|
|
69
|
+
session_id: str | None = None,
|
|
70
|
+
task_id: str | None = None,
|
|
71
|
+
workspace: str | None = None,
|
|
72
|
+
git_head: str | None = None,
|
|
73
|
+
tool: str | None = None,
|
|
74
|
+
tool_exit_code: int | None = None,
|
|
75
|
+
parent_message_id: str | None = None,
|
|
76
|
+
references: Iterable[str] | None = None,
|
|
77
|
+
artifact_headers: dict[str, str | Iterable[str]] | None = None,
|
|
78
|
+
extra_headers: dict[str, str | Iterable[str]] | None = None,
|
|
79
|
+
message_id: str | None = None,
|
|
80
|
+
) -> EmailMessage:
|
|
81
|
+
if not event_type:
|
|
82
|
+
raise AgentDirError("event_type is required")
|
|
83
|
+
|
|
84
|
+
msg = EmailMessage(policy=policy.default)
|
|
85
|
+
msg["Message-ID"] = message_id or make_msgid(domain="agentdir.local")
|
|
86
|
+
msg["Date"] = format_datetime(datetime.now(UTC))
|
|
87
|
+
msg["From"] = actor_address(from_actor)
|
|
88
|
+
msg["To"] = actor_address(to_actor or session_id or "agentdir")
|
|
89
|
+
msg["Subject"] = subject or event_type
|
|
90
|
+
msg["X-AgentDir-Version"] = AGENTDIR_VERSION
|
|
91
|
+
msg["X-AgentDir-Event-Type"] = event_type
|
|
92
|
+
msg["X-AgentDir-Created-Ns"] = str(time.time_ns())
|
|
93
|
+
|
|
94
|
+
optional = {
|
|
95
|
+
"X-AgentDir-Session": session_id,
|
|
96
|
+
"X-AgentDir-Task": task_id,
|
|
97
|
+
"X-AgentDir-Workspace": workspace,
|
|
98
|
+
"X-AgentDir-Git-Head": git_head,
|
|
99
|
+
"X-AgentDir-Tool": tool,
|
|
100
|
+
"X-AgentDir-Tool-Exit-Code": str(tool_exit_code) if tool_exit_code is not None else None,
|
|
101
|
+
}
|
|
102
|
+
for name, value in optional.items():
|
|
103
|
+
if value:
|
|
104
|
+
msg[name] = value
|
|
105
|
+
|
|
106
|
+
if parent_message_id:
|
|
107
|
+
msg["In-Reply-To"] = parent_message_id
|
|
108
|
+
refs = list(references or [])
|
|
109
|
+
if parent_message_id and parent_message_id not in refs:
|
|
110
|
+
refs.append(parent_message_id)
|
|
111
|
+
if refs:
|
|
112
|
+
msg["References"] = " ".join(refs)
|
|
113
|
+
|
|
114
|
+
for source in (artifact_headers or {}, extra_headers or {}):
|
|
115
|
+
for name, value in source.items():
|
|
116
|
+
if value is None:
|
|
117
|
+
continue
|
|
118
|
+
if isinstance(value, str):
|
|
119
|
+
msg[name] = value
|
|
120
|
+
continue
|
|
121
|
+
for item in value:
|
|
122
|
+
if item is not None:
|
|
123
|
+
msg[name] = str(item)
|
|
124
|
+
|
|
125
|
+
msg.set_content(body, subtype="plain", charset="utf-8")
|
|
126
|
+
return msg
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def envelope_bytes(message: EmailMessage) -> bytes:
|
|
130
|
+
return message.as_bytes(policy=policy.default.clone(linesep="\n"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_envelope(path: Path) -> ParsedEnvelope:
|
|
134
|
+
raw = path.read_bytes()
|
|
135
|
+
return ParsedEnvelope(
|
|
136
|
+
message=BytesParser(policy=policy.default).parsebytes(raw),
|
|
137
|
+
raw=raw,
|
|
138
|
+
path=path,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def validate_required(parsed: ParsedEnvelope) -> list[str]:
|
|
143
|
+
return [
|
|
144
|
+
name
|
|
145
|
+
for name in ("Message-ID", "Date", "From", "To", "X-AgentDir-Version", "X-AgentDir-Event-Type")
|
|
146
|
+
if not parsed.message.get(name)
|
|
147
|
+
]
|
agentdir/events.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from .artifacts import add_artifact, artifact_headers
|
|
8
|
+
from .envelope import build_envelope, envelope_bytes
|
|
9
|
+
from .mailbox import atomic_deliver
|
|
10
|
+
from .redaction import redact_text
|
|
11
|
+
from .store import init_root, session_mailbox
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class EmittedEvent:
|
|
16
|
+
path: Path
|
|
17
|
+
session_id: str
|
|
18
|
+
event_type: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emit_event(
|
|
22
|
+
root: str | Path,
|
|
23
|
+
*,
|
|
24
|
+
session_id: str,
|
|
25
|
+
event_type: str,
|
|
26
|
+
body: str,
|
|
27
|
+
subject: str | None = None,
|
|
28
|
+
from_actor: str | None = None,
|
|
29
|
+
to_actor: str | None = None,
|
|
30
|
+
task_id: str | None = None,
|
|
31
|
+
workspace: str | None = None,
|
|
32
|
+
git_head: str | None = None,
|
|
33
|
+
tool: str | None = None,
|
|
34
|
+
tool_exit_code: int | None = None,
|
|
35
|
+
parent_message_id: str | None = None,
|
|
36
|
+
artifact: str | Path | None = None,
|
|
37
|
+
extra_headers: dict[str, str | Iterable[str]] | None = None,
|
|
38
|
+
message_id: str | None = None,
|
|
39
|
+
) -> EmittedEvent:
|
|
40
|
+
init_root(root)
|
|
41
|
+
stored_artifact = add_artifact(root, artifact) if artifact else None
|
|
42
|
+
redacted = redact_text(body)
|
|
43
|
+
headers = dict(extra_headers or {})
|
|
44
|
+
if redacted.replacements:
|
|
45
|
+
headers["X-AgentDir-Redactions"] = str(_redaction_count(headers) + redacted.replacements)
|
|
46
|
+
headers["X-AgentDir-Redaction-Labels"] = ",".join(redacted.labels)
|
|
47
|
+
message = build_envelope(
|
|
48
|
+
event_type=event_type,
|
|
49
|
+
body=redacted.text,
|
|
50
|
+
subject=subject,
|
|
51
|
+
from_actor=from_actor,
|
|
52
|
+
to_actor=to_actor,
|
|
53
|
+
session_id=session_id,
|
|
54
|
+
task_id=task_id,
|
|
55
|
+
workspace=workspace,
|
|
56
|
+
git_head=git_head,
|
|
57
|
+
tool=tool,
|
|
58
|
+
tool_exit_code=tool_exit_code,
|
|
59
|
+
parent_message_id=parent_message_id,
|
|
60
|
+
artifact_headers=artifact_headers(stored_artifact) if stored_artifact else {},
|
|
61
|
+
extra_headers=headers,
|
|
62
|
+
message_id=message_id,
|
|
63
|
+
)
|
|
64
|
+
delivered = atomic_deliver(session_mailbox(root, session_id), envelope_bytes(message))
|
|
65
|
+
return EmittedEvent(path=delivered, session_id=session_id, event_type=event_type)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _redaction_count(headers: dict[str, str | Iterable[str]]) -> int:
|
|
69
|
+
try:
|
|
70
|
+
return int(str(headers.get("X-AgentDir-Redactions") or "0"))
|
|
71
|
+
except ValueError:
|
|
72
|
+
return 0
|