ai-agent-tap 0.3.3__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.
Files changed (43) hide show
  1. agent_tap/__init__.py +3 -0
  2. agent_tap/__main__.py +5 -0
  3. agent_tap/adapters/__init__.py +0 -0
  4. agent_tap/adapters/base.py +120 -0
  5. agent_tap/adapters/claude_code.py +312 -0
  6. agent_tap/adapters/codex.py +362 -0
  7. agent_tap/adapters/copilot_cli.py +388 -0
  8. agent_tap/adapters/declarative.py +263 -0
  9. agent_tap/adapters/hermes.py +575 -0
  10. agent_tap/adapters/omniroute.py +349 -0
  11. agent_tap/adapters/raven.py +836 -0
  12. agent_tap/adapters/service.py +599 -0
  13. agent_tap/bugreport.py +354 -0
  14. agent_tap/cli.py +354 -0
  15. agent_tap/core/__init__.py +0 -0
  16. agent_tap/core/config.py +96 -0
  17. agent_tap/core/paths.py +95 -0
  18. agent_tap/core/schema.py +73 -0
  19. agent_tap/dashboard/__init__.py +757 -0
  20. agent_tap/dashboard/static/index.html +926 -0
  21. agent_tap/digest.py +352 -0
  22. agent_tap/emitter.py +378 -0
  23. agent_tap/examples/aider.toml +16 -0
  24. agent_tap/examples/cline.toml +22 -0
  25. agent_tap/examples/services/everos.toml +6 -0
  26. agent_tap/examples/services/firecrawl.toml +13 -0
  27. agent_tap/examples/services/freellmapi.toml +6 -0
  28. agent_tap/examples/services/generic-docker.toml +7 -0
  29. agent_tap/examples/services/generic-http.toml +8 -0
  30. agent_tap/examples/services/searxng.toml +6 -0
  31. agent_tap/onboard.py +102 -0
  32. agent_tap/onboarding.py +193 -0
  33. agent_tap/platform/__init__.py +0 -0
  34. agent_tap/platform/scheduler.py +846 -0
  35. agent_tap/platform/service.py +141 -0
  36. agent_tap/redact.py +46 -0
  37. agent_tap/state.py +288 -0
  38. ai_agent_tap-0.3.3.dist-info/METADATA +298 -0
  39. ai_agent_tap-0.3.3.dist-info/RECORD +43 -0
  40. ai_agent_tap-0.3.3.dist-info/WHEEL +5 -0
  41. ai_agent_tap-0.3.3.dist-info/entry_points.txt +10 -0
  42. ai_agent_tap-0.3.3.dist-info/licenses/LICENSE +21 -0
  43. ai_agent_tap-0.3.3.dist-info/top_level.txt +1 -0
agent_tap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agent-tap: Near-real-time correlated session analytics & error OTLP collector ."""
2
+
3
+ __version__ = "0.3.2"
agent_tap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
File without changes
@@ -0,0 +1,120 @@
1
+ """Adapter contract and discovery (entry-point group ``agent_tap.harnesses`` + declarative TOML adapters)."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass
6
+ from importlib.metadata import entry_points
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
9
+
10
+ from ..core.config import Config
11
+
12
+ if TYPE_CHECKING:
13
+ from ..emitter import SpanStore
14
+ from ..state import StateStore
15
+
16
+ ENTRY_POINT_GROUP = "agent_tap.harnesses"
17
+
18
+ # stage name -> (spans_emitted, errors_emitted)
19
+ PollResult = dict[str, tuple[int, int]]
20
+
21
+
22
+ @dataclass
23
+ class Context:
24
+ config: Config
25
+
26
+ def dir_for(self, name: str, default: Path | None = None) -> Path | None:
27
+ """Config/env override for an adapter's data dir, else the OS-discovered default."""
28
+ return self.config.harness_dir(name) or default
29
+
30
+
31
+ @runtime_checkable
32
+ class HarnessAdapter(Protocol):
33
+ name: str
34
+ kind: str # "harness" (default when missing), "gateway" or "service"
35
+
36
+ def discover(self, ctx: Context) -> bool:
37
+ """True when this harness has data on this machine."""
38
+
39
+ def poll(self, ctx: Context, state: "StateStore", store: "SpanStore", cutoff_ts: float) -> PollResult:
40
+ """Emit spans newer than cutoff_ts into store; return per-stage (spans, errors)."""
41
+
42
+
43
+ def builtin_adapters() -> dict[str, Callable[[], HarnessAdapter]]:
44
+ """Fallback registry so a source checkout works before entry points are (re)installed."""
45
+ from .claude_code import ClaudeCodeAdapter
46
+ from .codex import CodexAdapter
47
+ from .copilot_cli import CopilotCliAdapter
48
+ from .hermes import HermesAdapter
49
+ from .omniroute import OmniRouteAdapter
50
+ from .raven import RavenAdapter
51
+
52
+ from .service import BUILTIN_SERVICE_ADAPTERS
53
+
54
+ return {
55
+ **BUILTIN_SERVICE_ADAPTERS,
56
+ "raven": RavenAdapter, "hermes": HermesAdapter, "claude-code": ClaudeCodeAdapter,
57
+ "codex": CodexAdapter, "copilot-cli": CopilotCliAdapter, "omniroute": OmniRouteAdapter,
58
+ }
59
+
60
+
61
+ def adapter_kind(adapter: Any) -> str:
62
+ from ..core.schema import SERVICE_KINDS, service_kind_for
63
+
64
+ kind = getattr(adapter, "kind", None)
65
+ return kind if kind in SERVICE_KINDS else service_kind_for(adapter.name)
66
+
67
+
68
+ def merge_service_adapters(adapters: list, extra: list) -> list:
69
+ """Add service adapters; a configured service replaces an adapter with the same name."""
70
+ names = {a.name for a in extra}
71
+ return [a for a in adapters if a.name not in names] + list(extra)
72
+
73
+
74
+ def load_adapters(config: Config) -> list[HarnessAdapter]:
75
+ """Built-ins + third-party entry points + declarative ``*.toml`` in config.adapters_dir."""
76
+ factories: dict[str, Callable[[], HarnessAdapter]] = dict(builtin_adapters())
77
+ for ep in entry_points(group=ENTRY_POINT_GROUP):
78
+ try:
79
+ factories[ep.name] = ep.load()
80
+ except Exception:
81
+ continue
82
+ adapters = [f() for f in factories.values()]
83
+ from .declarative import load_declarative_adapters
84
+ from .service import load_service_adapters
85
+
86
+ adapters += load_declarative_adapters(config.adapters_dir)
87
+ adapters = merge_service_adapters(adapters, load_service_adapters(config))
88
+ if config.enabled_services is not None:
89
+ adapters = [a for a in adapters if a.name in config.enabled_services]
90
+ return adapters
91
+
92
+
93
+ def run_adapters(
94
+ adapters: list[HarnessAdapter], ctx: Context, state: "StateStore", store: "SpanStore", cutoff_ts: float
95
+ ) -> dict[str, dict[str, Any]]:
96
+ """Poll every discovered adapter, isolating failures and recording per-stage source health."""
97
+ results: dict[str, dict[str, Any]] = {}
98
+ for ad in adapters:
99
+ try:
100
+ if not ad.discover(ctx):
101
+ continue
102
+ stages = ad.poll(ctx, state, store, cutoff_ts)
103
+ store.flush()
104
+ for stage, (sp, err) in stages.items():
105
+ state.record_source_health(
106
+ stage, spans_emitted=sp, errors_emitted=err,
107
+ last_event_at=time.time() if sp > 0 else None, last_error=None,
108
+ )
109
+ results[stage] = {"spans": sp, "errors": err}
110
+ except Exception as e: # one broken adapter must not stop the others
111
+ try:
112
+ state.record_source_health(ad.name, last_error=str(e))
113
+ except Exception:
114
+ pass
115
+ results[ad.name] = {"spans": 0, "errors": 0, "exception": str(e)}
116
+ try:
117
+ state.commit()
118
+ except Exception:
119
+ pass
120
+ return results
@@ -0,0 +1,312 @@
1
+ import glob
2
+ import json
3
+ import os
4
+ import re
5
+ import sqlite3
6
+ import subprocess
7
+ import time
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from ..core import paths
13
+ from ..emitter import SpanStore as PhoenixEmitter, build_span, ensure_utc
14
+ from ..state import SessionLink, StateStore, make_span_id, make_trace_id
15
+ from .base import Context, PollResult
16
+
17
+ _ROOTS: list[Path] = []
18
+ def poll_claude(
19
+ store: StateStore,
20
+ emitter: PhoenixEmitter,
21
+ cutoff_ts: float,
22
+ ) -> tuple[int, int]:
23
+ spans_count = 0
24
+ errors_count = 0
25
+ if not _ROOTS:
26
+ return 0, 0
27
+
28
+ for cf in [f for r in _ROOTS for f in glob.glob(str(r / "**" / "*.jsonl"), recursive=True)]:
29
+ try:
30
+ st = os.stat(cf)
31
+ except OSError:
32
+ continue
33
+ if st.st_mtime < cutoff_ts:
34
+ continue
35
+
36
+ offset, prev_mtime_ns, prev_ctime = store.get_file_watermark(cf)
37
+ if st.st_size < offset or (prev_ctime and int(st.st_ctime) != prev_ctime):
38
+ offset = 0
39
+ if st.st_size == offset and st.st_mtime_ns == prev_mtime_ns:
40
+ continue
41
+
42
+ p = Path(cf)
43
+ is_subagent = p.parent.name == "subagents"
44
+ file_stem = p.stem
45
+
46
+ # Inspect header lines to determine sessionId / parentSessionId
47
+ parent_claude_sid: Optional[str] = None
48
+ claude_sid = file_stem
49
+ cwd = ""
50
+ first_ts = st.st_ctime
51
+ try:
52
+ with open(cf, "r", encoding="utf-8", errors="replace") as f:
53
+ for _ in range(5):
54
+ l0 = f.readline()
55
+ if not l0:
56
+ break
57
+ d0 = json.loads(l0)
58
+ if d0.get("type") == "fork-context-ref":
59
+ parent_claude_sid = d0.get("parentSessionId")
60
+ claude_sid = f"{parent_claude_sid}:subagent:{d0.get('agentId') or file_stem}"
61
+ if d0.get("sessionId") and not is_subagent:
62
+ claude_sid = d0["sessionId"]
63
+ if d0.get("cwd") and not cwd:
64
+ cwd = d0["cwd"]
65
+ if d0.get("timestamp") and first_ts == st.st_ctime:
66
+ first_ts = d0["timestamp"]
67
+ except Exception:
68
+ pass
69
+
70
+ lookup_sid = parent_claude_sid if (is_subagent and parent_claude_sid) else claude_sid
71
+ st_dt = ensure_utc(first_ts)
72
+ link = store.resolve_session_link("claude", lookup_sid, int(st_dt.timestamp() * 1000))
73
+
74
+ if link:
75
+ trace_id = link.trace_id
76
+ top_sess_id = link.raven_session_key
77
+ if is_subagent and parent_claude_sid:
78
+ parent_span_id = make_span_id("claude", "session", parent_claude_sid)
79
+ conf = "exact_2nd_degree"
80
+ else:
81
+ parent_span_id = link.raven_node_span_id
82
+ conf = link.confidence
83
+ else:
84
+ root_sid = parent_claude_sid if (is_subagent and parent_claude_sid) else claude_sid
85
+ trace_id = make_trace_id("claude", root_sid)
86
+ top_sess_id = root_sid
87
+ parent_span_id = make_span_id("claude", "session", parent_claude_sid) if (is_subagent and parent_claude_sid) else None
88
+ conf = "exact_claude_subagent" if is_subagent else "standalone"
89
+
90
+ sess_span_id = make_span_id("claude", "session", claude_sid)
91
+ if offset == 0:
92
+ emitter.add(
93
+ build_span(
94
+ trace_id=trace_id,
95
+ span_id=sess_span_id,
96
+ name=f"claude.{'subagent' if is_subagent else 'session'} ({file_stem[:18]})",
97
+ span_kind="AGENT",
98
+ parent_id=parent_span_id,
99
+ start_time=first_ts,
100
+ end_time=st.st_mtime,
101
+ status_code="OK",
102
+ attributes={
103
+ "session.id": top_sess_id,
104
+ "child.session_id": claude_sid,
105
+ "harness": "claude",
106
+ "agent.name": "Claude Code Subagent" if is_subagent else "Claude Code",
107
+ "working_directory": cwd,
108
+ "correlation.confidence": conf,
109
+ },
110
+ )
111
+ )
112
+ spans_count += 1
113
+
114
+ try:
115
+ with open(cf, "rb") as bf:
116
+ bf.seek(offset)
117
+ raw = bf.read()
118
+ new_offset = bf.tell()
119
+ except OSError:
120
+ continue
121
+
122
+ for idx, line in enumerate(raw.decode("utf-8", errors="replace").splitlines()):
123
+ if not line.strip():
124
+ continue
125
+ try:
126
+ rec = json.loads(line)
127
+ except Exception:
128
+ continue
129
+ rtype = rec.get("type")
130
+ ts = rec.get("timestamp") or st.st_mtime
131
+ uuid = rec.get("uuid") or f"{offset}_{idx}"
132
+ msg = rec.get("message") or {}
133
+ content_list = msg.get("content") if isinstance(msg, dict) else None
134
+
135
+ if rtype == "user":
136
+ if isinstance(content_list, list):
137
+ for c_idx, item in enumerate(content_list):
138
+ if not isinstance(item, dict):
139
+ continue
140
+ if item.get("type") == "tool_result":
141
+ is_err = bool(item.get("is_error"))
142
+ t_out = str(item.get("content") or "")
143
+ if is_err:
144
+ errors_count += 1
145
+ err_cls = None
146
+ if is_err:
147
+ err_cls = "claude_sleep_guardrail_blocked" if "Blocked: sleep" in t_out else "claude_tool_error"
148
+ emitter.add(
149
+ build_span(
150
+ trace_id=trace_id,
151
+ span_id=make_span_id("claude", "tool_res", claude_sid, uuid, c_idx),
152
+ name="claude.tool_result",
153
+ span_kind="TOOL",
154
+ parent_id=sess_span_id,
155
+ start_time=ts,
156
+ end_time=ts,
157
+ status_code="ERROR" if is_err else "OK",
158
+ status_message=t_out[:500] if is_err else "",
159
+ attributes={
160
+ "session.id": top_sess_id,
161
+ "child.session_id": claude_sid,
162
+ "harness": "claude",
163
+ "tool.id": item.get("tool_use_id") or "",
164
+ "output.value": t_out[:12000],
165
+ "error.class": err_cls,
166
+ },
167
+ )
168
+ )
169
+ spans_count += 1
170
+ elif item.get("type") == "text":
171
+ emitter.add(
172
+ build_span(
173
+ trace_id=trace_id,
174
+ span_id=make_span_id("claude", "user", claude_sid, uuid, c_idx),
175
+ name="claude.turn.user",
176
+ span_kind="CHAIN",
177
+ parent_id=sess_span_id,
178
+ start_time=ts,
179
+ end_time=ts,
180
+ status_code="OK",
181
+ attributes={
182
+ "session.id": top_sess_id,
183
+ "child.session_id": claude_sid,
184
+ "harness": "claude",
185
+ "input.value": str(item.get("text") or "")[:16000],
186
+ },
187
+ )
188
+ )
189
+ spans_count += 1
190
+ elif isinstance(msg.get("content"), str):
191
+ emitter.add(
192
+ build_span(
193
+ trace_id=trace_id,
194
+ span_id=make_span_id("claude", "user", claude_sid, uuid),
195
+ name="claude.turn.user",
196
+ span_kind="CHAIN",
197
+ parent_id=sess_span_id,
198
+ start_time=ts,
199
+ end_time=ts,
200
+ status_code="OK",
201
+ attributes={
202
+ "session.id": top_sess_id,
203
+ "child.session_id": claude_sid,
204
+ "harness": "claude",
205
+ "input.value": msg["content"][:16000],
206
+ },
207
+ )
208
+ )
209
+ spans_count += 1
210
+
211
+ elif rtype == "assistant" and isinstance(msg, dict):
212
+ usage = msg.get("usage") or {}
213
+ in_tok = (
214
+ int(usage.get("input_tokens") or 0)
215
+ + int(usage.get("cache_creation_input_tokens") or 0)
216
+ + int(usage.get("cache_read_input_tokens") or 0)
217
+ )
218
+ out_tok = int(usage.get("output_tokens") or 0)
219
+ model_nm = rec.get("requestedModel") or msg.get("model") or ""
220
+ texts = []
221
+ tool_uses = []
222
+ if isinstance(content_list, list):
223
+ for item in content_list:
224
+ if isinstance(item, dict):
225
+ if item.get("type") == "text":
226
+ texts.append(str(item.get("text") or ""))
227
+ elif item.get("type") == "tool_use":
228
+ tool_uses.append(item)
229
+
230
+ asst_span_id = make_span_id("claude", "asst", claude_sid, uuid)
231
+ emitter.add(
232
+ build_span(
233
+ trace_id=trace_id,
234
+ span_id=asst_span_id,
235
+ name="claude.turn.assistant",
236
+ span_kind="LLM",
237
+ parent_id=sess_span_id,
238
+ start_time=ts,
239
+ end_time=ts,
240
+ status_code="OK",
241
+ attributes={
242
+ "session.id": top_sess_id,
243
+ "child.session_id": claude_sid,
244
+ "harness": "claude",
245
+ "llm.model_name": model_nm,
246
+ "llm.token_count.prompt": in_tok,
247
+ "llm.token_count.completion": out_tok,
248
+ "llm.token_count.total": in_tok + out_tok,
249
+ "output.value": "\n".join(texts)[:16000],
250
+ "tool.call_count": len(tool_uses),
251
+ },
252
+ )
253
+ )
254
+ spans_count += 1
255
+
256
+ for tu in tool_uses:
257
+ tname = tu.get("name") or "tool"
258
+ tid = tu.get("id") or uuid
259
+ tinput = tu.get("input") or {}
260
+ is_skill = tname in ("Skill", "read_skill", "use_skill")
261
+ emitter.add(
262
+ build_span(
263
+ trace_id=trace_id,
264
+ span_id=make_span_id("claude", "tool_use", claude_sid, tid),
265
+ name=f"claude.{'skill' if is_skill else 'tool'}.{tname}",
266
+ span_kind="TOOL",
267
+ parent_id=asst_span_id,
268
+ start_time=ts,
269
+ end_time=ts,
270
+ status_code="OK",
271
+ attributes={
272
+ "session.id": top_sess_id,
273
+ "child.session_id": claude_sid,
274
+ "harness": "claude",
275
+ "tool.name": tname,
276
+ "tool.id": tid,
277
+ "is_skill": is_skill,
278
+ "input.value": json.dumps(tinput, ensure_ascii=False)[:8000],
279
+ },
280
+ )
281
+ )
282
+ spans_count += 1
283
+
284
+ store.set_file_watermark("claude", cf, new_offset, st.st_mtime_ns, int(st.st_ctime))
285
+
286
+ return spans_count, errors_count
287
+
288
+
289
+ # ============================================================================
290
+
291
+
292
+ class ClaudeCodeAdapter:
293
+ name = "claude-code"
294
+
295
+ def discover(self, ctx: Context) -> bool:
296
+ return bool(self._roots(ctx))
297
+
298
+ @staticmethod
299
+ def _roots(ctx: Context) -> list[Path]:
300
+ o = ctx.dir_for("claude-code")
301
+ return [o] if o else paths.claude_roots()
302
+
303
+ def poll(self, ctx: Context, state: StateStore, store: PhoenixEmitter, cutoff_ts: float) -> PollResult:
304
+ global _ROOTS
305
+ _ROOTS = self._roots(ctx)
306
+ out: PollResult = {}
307
+ try:
308
+ out["claude"] = poll_claude(state, store, cutoff_ts)
309
+ except Exception as e:
310
+ state.record_source_health("claude", last_error=str(e))
311
+ out["claude"] = (0, 0)
312
+ return out