computer-agent-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- computer_agent_py-0.1.0.dist-info/METADATA +307 -0
- computer_agent_py-0.1.0.dist-info/RECORD +25 -0
- computer_agent_py-0.1.0.dist-info/WHEEL +4 -0
- computeragent/__init__.py +90 -0
- computeragent/_proxy/__init__.py +8 -0
- computeragent/_proxy/client.py +225 -0
- computeragent/_proxy/query.py +165 -0
- computeragent/policy/__init__.py +59 -0
- computeragent/policy/authorizer.py +161 -0
- computeragent/policy/cedar.py +182 -0
- computeragent/policy/opa.py +124 -0
- computeragent/policy/types.py +121 -0
- computeragent/py.typed +0 -0
- computeragent/telemetry/__init__.py +24 -0
- computeragent/telemetry/config.py +176 -0
- computeragent/telemetry/event.py +355 -0
- computeragent/telemetry/middleware/__init__.py +8 -0
- computeragent/telemetry/middleware/guardrails.py +127 -0
- computeragent/telemetry/middleware/pii.py +158 -0
- computeragent/telemetry/pipeline.py +160 -0
- computeragent/telemetry/sinks/__init__.py +28 -0
- computeragent/telemetry/sinks/agentos.py +442 -0
- computeragent/telemetry/sinks/message_archive.py +136 -0
- computeragent/telemetry/sinks/otel.py +375 -0
- computeragent/types.py +13 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""``AgentRegistrySink`` — write Mongo docs that AgentOS reads.
|
|
2
|
+
|
|
3
|
+
Mirrors the TS ``@open-gitagent/agent-registry-mongo`` collections:
|
|
4
|
+
|
|
5
|
+
* ``agent_registry`` — one doc per agent name, idempotent upsert on each
|
|
6
|
+
``session_started``. First write sets ``registeredAt``; every subsequent
|
|
7
|
+
write refreshes ``updatedAt`` + ``lastSeen``.
|
|
8
|
+
* ``agent_logs`` — one doc per run (``session_ended``), with truncated
|
|
9
|
+
``query`` / ``reply``, token + cost rollups.
|
|
10
|
+
* ``sessions`` — one doc per session with an ``entries[]`` array of
|
|
11
|
+
``{type, text}`` chat-bubble messages. Mirrors the harness-owned shape so
|
|
12
|
+
the AgentOS SPA ``Chat`` tab can render Python-driven sessions out of the
|
|
13
|
+
box. ``user_message`` and ``assistant_message`` events are appended;
|
|
14
|
+
tool_use / tool_result / usage_snapshot / policy_decision are skipped
|
|
15
|
+
(they have no home in the ``{type, text}`` schema and are instead
|
|
16
|
+
captured by ``MongoMessageSink`` in ``agent_messages``). Opt out with
|
|
17
|
+
``AgentRegistrySink(write_sessions=False)``.
|
|
18
|
+
|
|
19
|
+
Truncation limits match TS exactly (``QUERY_MAX=8000``, ``REPLY_MAX=16000``)
|
|
20
|
+
so docs written from either runtime have the same shape. Uses only
|
|
21
|
+
DocumentDB-compatible operators (``$set``, ``$setOnInsert``, ``$push``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import socket
|
|
30
|
+
import uuid
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
36
|
+
except ImportError as _exc: # pragma: no cover - guarded by lazy export
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"AgentRegistrySink requires the [agentos] extra. "
|
|
39
|
+
"Install with: pip install 'computer-agent-py[agentos]'"
|
|
40
|
+
) from _exc
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from ..event import TelemetryEvent
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
QUERY_MAX = 8_000
|
|
49
|
+
REPLY_MAX = 16_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentRegistrySink:
|
|
53
|
+
"""Write ``agent_registry`` + ``agent_logs`` docs.
|
|
54
|
+
|
|
55
|
+
Parameters mirror the TS package's options. ``mongo_url`` accepts standard
|
|
56
|
+
MongoDB URIs (``mongodb://...`` or ``mongodb+srv://...``).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
mongo_url: str | None = None,
|
|
63
|
+
database: str | None = None,
|
|
64
|
+
registry_collection: str = "agent_registry",
|
|
65
|
+
logs_collection: str = "agent_logs",
|
|
66
|
+
sessions_collection: str = "sessions",
|
|
67
|
+
threads_collection: str = "slack_threads",
|
|
68
|
+
client: Any | None = None,
|
|
69
|
+
source: str = "library",
|
|
70
|
+
registered_by: str | None = None,
|
|
71
|
+
write_sessions: bool = True,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._mongo_url = mongo_url or os.environ.get("AGENTOS_MONGO_URL")
|
|
74
|
+
self._database = database or os.environ.get("AGENTOS_MONGO_DB", "agentos")
|
|
75
|
+
if not self._mongo_url and client is None:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"AgentRegistrySink requires mongo_url= (or AGENTOS_MONGO_URL env) "
|
|
78
|
+
"or a client= argument"
|
|
79
|
+
)
|
|
80
|
+
self._client: Any = client or AsyncIOMotorClient(self._mongo_url)
|
|
81
|
+
self._db = self._client[self._database]
|
|
82
|
+
self._registry = self._db[registry_collection]
|
|
83
|
+
self._logs = self._db[logs_collection]
|
|
84
|
+
self._sessions = self._db[sessions_collection]
|
|
85
|
+
# `slack_threads` is the TS aggregation table — agents.ts derives
|
|
86
|
+
# sessionCount + lastActivity from it. One row per session.
|
|
87
|
+
self._threads = self._db[threads_collection]
|
|
88
|
+
self._source = source
|
|
89
|
+
self._registered_by = registered_by or socket.gethostname()
|
|
90
|
+
self._write_sessions = write_sessions
|
|
91
|
+
|
|
92
|
+
# session_id → started timestamp + prompt (so session_ended can fill query/duration).
|
|
93
|
+
self._session_meta: dict[str, dict[str, Any]] = {}
|
|
94
|
+
# Sessions we've already opened in Mongo this process — avoids redundant upserts.
|
|
95
|
+
self._sessions_opened: set[str] = set()
|
|
96
|
+
|
|
97
|
+
# ── lifecycle ----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def emit(self, event: TelemetryEvent) -> None:
|
|
100
|
+
if event.kind == "session_started":
|
|
101
|
+
await self._upsert_registry(event)
|
|
102
|
+
self._session_meta[event.session_id] = {
|
|
103
|
+
"started_at": event.timestamp,
|
|
104
|
+
"agent_name": event.agent_name,
|
|
105
|
+
"prompt": event.payload.get("prompt") or "",
|
|
106
|
+
"model": event.payload.get("model"),
|
|
107
|
+
}
|
|
108
|
+
if self._write_sessions:
|
|
109
|
+
# Track whether _open_session actually created the doc this
|
|
110
|
+
# call (first time we see this session id in-process) so we
|
|
111
|
+
# only seed the first user entry once per session.
|
|
112
|
+
first_open = event.session_id not in self._sessions_opened
|
|
113
|
+
await self._open_session(event)
|
|
114
|
+
await self._open_thread(event)
|
|
115
|
+
if first_open:
|
|
116
|
+
# Seed the first user entry from the prompt the agent was
|
|
117
|
+
# constructed with — matches what a human reading the
|
|
118
|
+
# SPA Chat tab expects to see as turn 1.
|
|
119
|
+
prompt_text = str(event.payload.get("prompt") or "")
|
|
120
|
+
if prompt_text:
|
|
121
|
+
await self._append_to_session(
|
|
122
|
+
event.session_id, {"type": "user", "text": prompt_text}
|
|
123
|
+
)
|
|
124
|
+
elif event.kind == "session_ended":
|
|
125
|
+
await self._insert_log(event)
|
|
126
|
+
if self._write_sessions:
|
|
127
|
+
await self._close_session(event)
|
|
128
|
+
await self._close_thread(event)
|
|
129
|
+
self._session_meta.pop(event.session_id, None)
|
|
130
|
+
self._sessions_opened.discard(event.session_id)
|
|
131
|
+
elif self._write_sessions and event.kind == "assistant_message":
|
|
132
|
+
text = str(event.payload.get("text") or "")
|
|
133
|
+
if text:
|
|
134
|
+
await self._append_to_session(event.session_id, {"type": "assistant", "text": text})
|
|
135
|
+
elif self._write_sessions and event.kind == "user_message":
|
|
136
|
+
text = str(event.payload.get("text") or "")
|
|
137
|
+
if text:
|
|
138
|
+
await self._append_to_session(event.session_id, {"type": "user", "text": text})
|
|
139
|
+
|
|
140
|
+
async def flush(self, timeout: float = 5.0) -> None:
|
|
141
|
+
# motor flushes on each operation; nothing to do.
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
async def aclose(self) -> None:
|
|
145
|
+
try:
|
|
146
|
+
self._client.close()
|
|
147
|
+
except Exception: # noqa: BLE001
|
|
148
|
+
logger.debug("motor client close failed", exc_info=True)
|
|
149
|
+
|
|
150
|
+
# ── writes -------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async def _upsert_registry(self, event: TelemetryEvent) -> None:
|
|
153
|
+
name = event.agent_name or _agent_name_from_event(event)
|
|
154
|
+
now = _utcnow()
|
|
155
|
+
await self._registry.update_one(
|
|
156
|
+
{"_id": name},
|
|
157
|
+
{
|
|
158
|
+
"$setOnInsert": {
|
|
159
|
+
"_id": name,
|
|
160
|
+
"registeredAt": now,
|
|
161
|
+
},
|
|
162
|
+
"$set": {
|
|
163
|
+
"harness": "claude-agent-sdk",
|
|
164
|
+
"source": _inline_source_for(name, event.payload),
|
|
165
|
+
"model": event.payload.get("model"),
|
|
166
|
+
"registeredBy": self._registered_by,
|
|
167
|
+
"updatedAt": now,
|
|
168
|
+
"lastSeen": now,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
upsert=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def _insert_log(self, event: TelemetryEvent) -> None:
|
|
175
|
+
meta = self._session_meta.get(event.session_id, {})
|
|
176
|
+
usage = event.payload.get("usage") or {}
|
|
177
|
+
agent_name = event.agent_name or meta.get("agent_name") or _agent_name_from_event(event)
|
|
178
|
+
doc = {
|
|
179
|
+
"_id": "log_" + uuid.uuid4().hex,
|
|
180
|
+
"ts": _utcnow(),
|
|
181
|
+
"source": self._source,
|
|
182
|
+
# TS agentos-server (agent-log-store.ts) keys per-agent counts on
|
|
183
|
+
# the field `bot` — a holdover from when only Slack bots wrote
|
|
184
|
+
# rows. Write both so the existing UI's per-agent logCount
|
|
185
|
+
# aggregation lights up for Python-driven runs.
|
|
186
|
+
"bot": agent_name,
|
|
187
|
+
"agentName": agent_name,
|
|
188
|
+
"requester": self._registered_by or None,
|
|
189
|
+
"channel": None,
|
|
190
|
+
"threadTs": None,
|
|
191
|
+
"sessionId": event.session_id,
|
|
192
|
+
"query": _truncate(meta.get("prompt", ""), QUERY_MAX),
|
|
193
|
+
"reply": _truncate(str(event.payload.get("result", "") or ""), REPLY_MAX),
|
|
194
|
+
"ok": not event.payload.get("is_error", False),
|
|
195
|
+
}
|
|
196
|
+
if event.payload.get("is_error"):
|
|
197
|
+
doc["error"] = str(
|
|
198
|
+
event.payload.get("error_message") or event.payload.get("subtype") or ""
|
|
199
|
+
)
|
|
200
|
+
duration_ms = event.payload.get("duration_ms")
|
|
201
|
+
if duration_ms is not None:
|
|
202
|
+
doc["durationMs"] = int(duration_ms)
|
|
203
|
+
for src_key, doc_key in (
|
|
204
|
+
("input_tokens", "inputTokens"),
|
|
205
|
+
("output_tokens", "outputTokens"),
|
|
206
|
+
):
|
|
207
|
+
v = usage.get(src_key)
|
|
208
|
+
if isinstance(v, int):
|
|
209
|
+
doc[doc_key] = v
|
|
210
|
+
cost = event.payload.get("total_cost_usd")
|
|
211
|
+
if cost is not None:
|
|
212
|
+
try:
|
|
213
|
+
doc["costUsd"] = float(cost)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
doc["costUsd"] = None
|
|
216
|
+
await self._logs.insert_one(doc)
|
|
217
|
+
|
|
218
|
+
# ── sessions (Chat tab) writes ────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async def _open_session(self, event: TelemetryEvent) -> None:
|
|
221
|
+
"""Idempotent upsert of the per-session doc. The AgentOS SPA's Chat
|
|
222
|
+
tab reads ``sessions.entries[]`` (normalized by ``sessions.ts``).
|
|
223
|
+
|
|
224
|
+
Uses ``$setOnInsert`` so re-runs that reuse a session id don't reset
|
|
225
|
+
the ``createdAt`` field, while ``$set`` keeps ``updatedAt`` fresh.
|
|
226
|
+
"""
|
|
227
|
+
if event.session_id in self._sessions_opened:
|
|
228
|
+
return
|
|
229
|
+
agent_name = event.agent_name or self._session_meta.get(event.session_id, {}).get(
|
|
230
|
+
"agent_name"
|
|
231
|
+
)
|
|
232
|
+
now = _utcnow()
|
|
233
|
+
await self._sessions.update_one(
|
|
234
|
+
{"_id": event.session_id},
|
|
235
|
+
{
|
|
236
|
+
"$setOnInsert": {
|
|
237
|
+
"_id": event.session_id,
|
|
238
|
+
"createdAt": now,
|
|
239
|
+
"entries": [],
|
|
240
|
+
},
|
|
241
|
+
"$set": {
|
|
242
|
+
"agentName": agent_name,
|
|
243
|
+
"source": self._source,
|
|
244
|
+
"model": (
|
|
245
|
+
event.payload.get("model")
|
|
246
|
+
or self._session_meta.get(event.session_id, {}).get("model")
|
|
247
|
+
),
|
|
248
|
+
"updatedAt": now,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
upsert=True,
|
|
252
|
+
)
|
|
253
|
+
self._sessions_opened.add(event.session_id)
|
|
254
|
+
|
|
255
|
+
async def _append_to_session(self, session_id: str, entry: dict[str, Any]) -> None:
|
|
256
|
+
# Cap individual entry text to REPLY_MAX so a single huge message
|
|
257
|
+
# can't push the doc over Mongo's 16MB ceiling.
|
|
258
|
+
text = entry.get("text")
|
|
259
|
+
if isinstance(text, str):
|
|
260
|
+
entry = {**entry, "text": _truncate(text, REPLY_MAX)}
|
|
261
|
+
await self._sessions.update_one(
|
|
262
|
+
{"_id": session_id},
|
|
263
|
+
{
|
|
264
|
+
"$push": {"entries": entry},
|
|
265
|
+
"$set": {"updatedAt": _utcnow()},
|
|
266
|
+
},
|
|
267
|
+
upsert=True,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def _open_thread(self, event: TelemetryEvent) -> None:
|
|
271
|
+
"""Idempotent upsert of a ``slack_threads`` row.
|
|
272
|
+
|
|
273
|
+
``agentos-server/src/routes/agents.ts`` derives per-agent
|
|
274
|
+
``sessionCount`` + ``lastActivity`` from this collection (a holdover
|
|
275
|
+
from when only Slack bots wrote rows). One row per session.
|
|
276
|
+
"""
|
|
277
|
+
agent_name = event.agent_name or self._session_meta.get(event.session_id, {}).get(
|
|
278
|
+
"agent_name"
|
|
279
|
+
)
|
|
280
|
+
if not agent_name:
|
|
281
|
+
return
|
|
282
|
+
now = _utcnow()
|
|
283
|
+
await self._threads.update_one(
|
|
284
|
+
{"_id": f"thread_{self._source}_{event.session_id}"},
|
|
285
|
+
{
|
|
286
|
+
"$setOnInsert": {
|
|
287
|
+
"_id": f"thread_{self._source}_{event.session_id}",
|
|
288
|
+
"createdAt": now,
|
|
289
|
+
},
|
|
290
|
+
"$set": {
|
|
291
|
+
"bot": agent_name,
|
|
292
|
+
"channel": self._source,
|
|
293
|
+
"threadTs": "",
|
|
294
|
+
"sessionId": event.session_id,
|
|
295
|
+
"sandboxId": None,
|
|
296
|
+
"snapshotId": None,
|
|
297
|
+
"lastMessageAt": now,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
upsert=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def _close_thread(self, event: TelemetryEvent) -> None:
|
|
304
|
+
"""Stamp ``lastMessageAt`` on session end so the agent's
|
|
305
|
+
``lastActivity`` reflects the latest run."""
|
|
306
|
+
await self._threads.update_one(
|
|
307
|
+
{"_id": f"thread_{self._source}_{event.session_id}"},
|
|
308
|
+
{"$set": {"lastMessageAt": _utcnow()}},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def _close_session(self, event: TelemetryEvent) -> None:
|
|
312
|
+
"""Stamp end-of-session metadata. Doesn't append to ``entries``."""
|
|
313
|
+
now = _utcnow()
|
|
314
|
+
update: dict[str, Any] = {
|
|
315
|
+
"endedAt": now,
|
|
316
|
+
"updatedAt": now,
|
|
317
|
+
"ok": not event.payload.get("is_error", False),
|
|
318
|
+
}
|
|
319
|
+
duration_ms = event.payload.get("duration_ms")
|
|
320
|
+
if duration_ms is not None:
|
|
321
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
322
|
+
update["durationMs"] = int(duration_ms)
|
|
323
|
+
cost = event.payload.get("total_cost_usd")
|
|
324
|
+
if cost is not None:
|
|
325
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
326
|
+
update["costUsd"] = float(cost)
|
|
327
|
+
await self._sessions.update_one(
|
|
328
|
+
{"_id": event.session_id},
|
|
329
|
+
{"$set": update},
|
|
330
|
+
upsert=True,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── helpers ────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _utcnow() -> datetime:
|
|
338
|
+
return datetime.now(timezone.utc)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _truncate(s: str, n: int) -> str:
|
|
342
|
+
if not isinstance(s, str):
|
|
343
|
+
s = str(s)
|
|
344
|
+
return s if len(s) <= n else s[:n]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _agent_name_from_event(event: TelemetryEvent) -> str:
|
|
348
|
+
"""Fallback agent name when none was supplied — first 32 chars of a hash."""
|
|
349
|
+
import hashlib
|
|
350
|
+
|
|
351
|
+
prompt = event.payload.get("prompt", "") if isinstance(event.payload, dict) else ""
|
|
352
|
+
model = event.payload.get("model", "") if isinstance(event.payload, dict) else ""
|
|
353
|
+
h = hashlib.sha256(f"{model}|{prompt}".encode()).hexdigest()[:12]
|
|
354
|
+
return f"anonymous-{h}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
DEFAULT_AGENT_INSTRUCTIONS = (
|
|
358
|
+
"You are a helpful assistant. Answer the user's question concisely and accurately."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _inline_source_for(name: str, options_payload: dict[str, Any]) -> dict[str, Any]:
|
|
363
|
+
"""Build a complete inline ``IdentitySource`` the harness can clone into a
|
|
364
|
+
sandbox workdir for live chat.
|
|
365
|
+
|
|
366
|
+
Captures the agent's ``ClaudeAgentOptions`` (system_prompt, model,
|
|
367
|
+
allowed_tools, max_turns, permission_mode) as ``agent.yaml`` plus a
|
|
368
|
+
``CLAUDE.md`` so the harness's inline loader can spin up a fresh sandbox
|
|
369
|
+
when the AgentOS SPA's "New Chat" button fires — matching the way
|
|
370
|
+
hosted (git-sourced) agents are already supported.
|
|
371
|
+
|
|
372
|
+
MCP server configs and the Python-side ``cwd`` are deliberately omitted —
|
|
373
|
+
they don't translate across processes/filesystems. Library-mode agents
|
|
374
|
+
can use built-in tools (Read, Glob, Grep) in live chat; custom MCP
|
|
375
|
+
servers stay Python-only.
|
|
376
|
+
"""
|
|
377
|
+
model = options_payload.get("model")
|
|
378
|
+
system_prompt = options_payload.get("system_prompt") or DEFAULT_AGENT_INSTRUCTIONS
|
|
379
|
+
allowed_tools = options_payload.get("allowed_tools") or ["Read", "Glob", "Grep"]
|
|
380
|
+
max_turns = options_payload.get("max_turns")
|
|
381
|
+
permission_mode = options_payload.get("permission_mode") or "bypassPermissions"
|
|
382
|
+
|
|
383
|
+
agent_yaml = _build_agent_yaml(
|
|
384
|
+
name=name,
|
|
385
|
+
model=model,
|
|
386
|
+
allowed_tools=allowed_tools,
|
|
387
|
+
max_turns=max_turns,
|
|
388
|
+
permission_mode=permission_mode,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
"type": "inline",
|
|
393
|
+
"manifest": {"name": name, "version": "0.1.0"},
|
|
394
|
+
"model": model,
|
|
395
|
+
"files": {
|
|
396
|
+
"agent.yaml": agent_yaml,
|
|
397
|
+
"CLAUDE.md": system_prompt,
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _build_agent_yaml(
|
|
403
|
+
*,
|
|
404
|
+
name: str,
|
|
405
|
+
model: str | None,
|
|
406
|
+
allowed_tools: list[str],
|
|
407
|
+
max_turns: int | None,
|
|
408
|
+
permission_mode: str,
|
|
409
|
+
) -> str:
|
|
410
|
+
"""Render the agent.yaml the harness's inline loader expects.
|
|
411
|
+
|
|
412
|
+
Minimal viable shape — name + version + model + a runtime block with the
|
|
413
|
+
allowed-tools and turn limit. Matches the pattern NordAssist's worker
|
|
414
|
+
uses in `_build_identity()` for its fallback inline source.
|
|
415
|
+
"""
|
|
416
|
+
safe_name = _yaml_str(name)
|
|
417
|
+
lines = [
|
|
418
|
+
'spec_version: "0.1.0"',
|
|
419
|
+
f"name: {safe_name}",
|
|
420
|
+
"version: 0.1.0",
|
|
421
|
+
]
|
|
422
|
+
if model:
|
|
423
|
+
lines += [
|
|
424
|
+
"model:",
|
|
425
|
+
f" preferred: {_yaml_str(model)}",
|
|
426
|
+
]
|
|
427
|
+
lines += ["runtime:"]
|
|
428
|
+
if max_turns is not None:
|
|
429
|
+
lines.append(f" max_turns: {int(max_turns)}")
|
|
430
|
+
lines.append(f" permission_mode: {_yaml_str(permission_mode)}")
|
|
431
|
+
if allowed_tools:
|
|
432
|
+
lines.append(" allowed_tools:")
|
|
433
|
+
for t in allowed_tools:
|
|
434
|
+
lines.append(f" - {_yaml_str(t)}")
|
|
435
|
+
return "\n".join(lines) + "\n"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _yaml_str(s: str) -> str:
|
|
439
|
+
"""Single-line scalar safe for YAML. Quotes everything to avoid the
|
|
440
|
+
headache of indicator characters (``:``, ``-``, ``*``, ``#``, …) in
|
|
441
|
+
agent names / system prompts."""
|
|
442
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""``MongoMessageSink`` — per-message archive in Mongo.
|
|
2
|
+
|
|
3
|
+
Writes one document per non-rollup telemetry event into the ``agent_messages``
|
|
4
|
+
collection (parallel to ``agent_registry`` + ``agent_logs`` written by
|
|
5
|
+
:class:`AgentRegistrySink`). Sits in the ``[agentos]`` extra alongside
|
|
6
|
+
``motor``, so installing AgentOS support enables both sinks.
|
|
7
|
+
|
|
8
|
+
Each document carries:
|
|
9
|
+
|
|
10
|
+
* ``sessionId`` / ``agentName`` — primary indexes for transcript queries
|
|
11
|
+
* ``kind`` — the :class:`EventKind` (``user_message``, ``assistant_message``,
|
|
12
|
+
``tool_use``, ``tool_result``, ``usage_snapshot``, ``policy_decision``,
|
|
13
|
+
``system_message``)
|
|
14
|
+
* ``payload`` — the post-middleware payload (PII-redacted by default; that's
|
|
15
|
+
the standard pipeline ordering — middleware runs before sinks)
|
|
16
|
+
* ``ts`` — emission time
|
|
17
|
+
* ``latencyMs`` — only present on ``policy_decision`` today
|
|
18
|
+
|
|
19
|
+
``session_started`` and ``session_ended`` are skipped: those rollup events
|
|
20
|
+
already land in ``agent_logs`` via :class:`AgentRegistrySink`. Writing them
|
|
21
|
+
here too would just duplicate the registry contract.
|
|
22
|
+
|
|
23
|
+
Use case: per-session transcript replay in the AgentOS frontend, RAG ingestion
|
|
24
|
+
over historical agent runs, audit / forensics — all of which need the full
|
|
25
|
+
message stream rather than the session-rollup view in ``agent_logs``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import socket
|
|
33
|
+
import uuid
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
39
|
+
except ImportError as _exc: # pragma: no cover - guarded by lazy export
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"MongoMessageSink requires the [agentos] extra. "
|
|
42
|
+
"Install with: pip install 'computer-agent-py[agentos]'"
|
|
43
|
+
) from _exc
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from ..event import TelemetryEvent
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
49
|
+
|
|
50
|
+
# Per-field truncation caps. Mongo's hard ceiling is 16 MB per doc; these
|
|
51
|
+
# defaults keep us well clear while still letting most real prompts /
|
|
52
|
+
# responses land verbatim.
|
|
53
|
+
DEFAULT_MAX_FIELD_CHARS = 32_000
|
|
54
|
+
|
|
55
|
+
# Kinds that the AgentRegistrySink already covers via agent_logs.
|
|
56
|
+
_ROLLUP_KINDS = {"session_started", "session_ended"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MongoMessageSink:
|
|
60
|
+
"""Per-message Mongo archive. Pairs with :class:`AgentRegistrySink`."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
mongo_url: str | None = None,
|
|
66
|
+
database: str | None = None,
|
|
67
|
+
collection: str = "agent_messages",
|
|
68
|
+
client: Any | None = None,
|
|
69
|
+
max_field_chars: int = DEFAULT_MAX_FIELD_CHARS,
|
|
70
|
+
source: str = "library",
|
|
71
|
+
host: str | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._mongo_url = mongo_url or os.environ.get("AGENTOS_MONGO_URL")
|
|
74
|
+
self._database = database or os.environ.get("AGENTOS_MONGO_DB", "agentos")
|
|
75
|
+
if not self._mongo_url and client is None:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"MongoMessageSink requires mongo_url= (or AGENTOS_MONGO_URL env) "
|
|
78
|
+
"or a client= argument"
|
|
79
|
+
)
|
|
80
|
+
self._client: Any = client or AsyncIOMotorClient(self._mongo_url)
|
|
81
|
+
self._coll = self._client[self._database][collection]
|
|
82
|
+
self._max_field_chars = max_field_chars
|
|
83
|
+
self._source = source
|
|
84
|
+
self._host = host or socket.gethostname()
|
|
85
|
+
|
|
86
|
+
async def emit(self, event: TelemetryEvent) -> None:
|
|
87
|
+
if event.kind in _ROLLUP_KINDS:
|
|
88
|
+
return # AgentRegistrySink owns these
|
|
89
|
+
doc = {
|
|
90
|
+
"_id": "msg_" + uuid.uuid4().hex,
|
|
91
|
+
"ts": event.timestamp or _utcnow(),
|
|
92
|
+
"source": self._source,
|
|
93
|
+
"host": self._host,
|
|
94
|
+
"sessionId": event.session_id,
|
|
95
|
+
"agentName": event.agent_name,
|
|
96
|
+
"kind": event.kind,
|
|
97
|
+
"payload": _truncate(event.payload, self._max_field_chars),
|
|
98
|
+
}
|
|
99
|
+
# Surface latency at the top level for the few event kinds that carry
|
|
100
|
+
# it — makes per-message latency dashboards trivial in Mongo / Atlas.
|
|
101
|
+
latency = (event.payload or {}).get("latency_ms")
|
|
102
|
+
if isinstance(latency, (int, float)):
|
|
103
|
+
doc["latencyMs"] = float(latency)
|
|
104
|
+
await self._coll.insert_one(doc)
|
|
105
|
+
|
|
106
|
+
async def flush(self, timeout: float = 5.0) -> None:
|
|
107
|
+
# motor flushes per operation; nothing to do.
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
async def aclose(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
self._client.close()
|
|
113
|
+
except Exception: # noqa: BLE001
|
|
114
|
+
logger.debug("motor client close failed", exc_info=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── helpers ────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _utcnow() -> datetime:
|
|
121
|
+
return datetime.now(timezone.utc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _truncate(value: object, limit: int) -> object:
|
|
125
|
+
"""Walk a payload, truncating any string longer than `limit` chars.
|
|
126
|
+
|
|
127
|
+
Mirrors GuardrailFilter's truncation shape — string + a footer noting how
|
|
128
|
+
many chars were dropped, so consumers see what was elided.
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
return {k: _truncate(v, limit) for k, v in value.items()}
|
|
132
|
+
if isinstance(value, list):
|
|
133
|
+
return [_truncate(v, limit) for v in value]
|
|
134
|
+
if isinstance(value, str) and len(value) > limit:
|
|
135
|
+
return value[:limit] + f"…(+{len(value) - limit} chars)"
|
|
136
|
+
return value
|