agentprof 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.
Files changed (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,399 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """The FastAPI application: session list, analysed sessions, node details and live updates."""
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+ from urllib.parse import quote
12
+
13
+ from fastapi import FastAPI, HTTPException, Query, Request
14
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
15
+
16
+ from agentprof.analysis.agent_summary import agent_ids
17
+ from agentprof.model import Node, Session, iter_nodes
18
+ from agentprof.pricing import PriceTable
19
+ from agentprof.registry import EventKind, Registry, RegistryEvent, SessionUnavailable
20
+ from agentprof.server.pricing_schema import PricingOut, pricing_out
21
+ from agentprof.server.schemas import (
22
+ AgentSummaryOut,
23
+ ApiEndpointOut,
24
+ ApiIndexOut,
25
+ DiagnosticsDetailOut,
26
+ FindingOut,
27
+ NodeDetailOut,
28
+ ProjectedSessionOut,
29
+ SessionOut,
30
+ SummaryOut,
31
+ activity_flags,
32
+ agent_summary_out,
33
+ diagnostics_detail_out,
34
+ diagnostics_out,
35
+ finding_out,
36
+ node_detail_out,
37
+ node_out,
38
+ session_out,
39
+ summary_out,
40
+ )
41
+
42
+ _KEEP_ALIVE_S = 15.0
43
+ _STATIC_DIR = Path(__file__).parent / "static"
44
+ _ASSETS_PREFIX = "assets/"
45
+ _IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"} # Vite puts a content hash in asset names
46
+ _NO_CACHE = {"Cache-Control": "no-cache"}
47
+ _SESSION_FIELDS = ("session", "diagnostics", "tree")
48
+ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
49
+ _PLACEHOLDER_PAGE = """<!doctype html>
50
+ <html lang="en">
51
+ <head><meta charset="utf-8"><title>agentprof</title><!-- API discovery: /api --></head>
52
+ <body>
53
+ <h1>agentprof</h1>
54
+ <p>The frontend assets are missing: the browser UI is not built yet.</p>
55
+ <p>The API is running: <a href="/api/sessions">/api/sessions</a> · <a href="/docs">/docs</a></p>
56
+ </body>
57
+ </html>
58
+ """
59
+
60
+
61
+ class Broadcaster:
62
+ """Hands registry events from any thread to the queues of connected event-stream clients.
63
+
64
+ A `None` in a queue tells its stream to end.
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self._loop: asyncio.AbstractEventLoop | None = None
69
+ self._queues: set[asyncio.Queue[RegistryEvent | None]] = set()
70
+
71
+ def bind(self, loop: asyncio.AbstractEventLoop) -> None:
72
+ self._loop = loop
73
+
74
+ def open(self) -> asyncio.Queue[RegistryEvent | None]:
75
+ queue: asyncio.Queue[RegistryEvent | None] = asyncio.Queue()
76
+ self._queues.add(queue)
77
+ return queue
78
+
79
+ def close(self, queue: asyncio.Queue[RegistryEvent | None]) -> None:
80
+ self._queues.discard(queue)
81
+
82
+ def publish(self, event: RegistryEvent) -> None:
83
+ """Thread-safe: deliver `event` on the server's event loop; dropped before the server has started."""
84
+ loop = self._loop
85
+ if loop is None or loop.is_closed():
86
+ return
87
+ loop.call_soon_threadsafe(self._deliver, event)
88
+
89
+ def _deliver(self, event: RegistryEvent | None) -> None:
90
+ for queue in list(self._queues):
91
+ queue.put_nowait(event)
92
+
93
+ def end_streams(self) -> None:
94
+ """Thread-safe: make every connected stream end, so a shutting-down server need not wait for the browser."""
95
+ loop = self._loop
96
+ if loop is None or loop.is_closed():
97
+ return
98
+ loop.call_soon_threadsafe(self._deliver, None)
99
+
100
+
101
+ def format_event(event: RegistryEvent, registry: Registry) -> str | None:
102
+ """The server-sent-event message for `event`, or `None` if its row vanished meanwhile."""
103
+ if event.kind is EventKind.REMOVED:
104
+ return f"event: removed\ndata: {json.dumps({'id': event.session_id})}\n\n"
105
+ row = registry.row(event.session_id)
106
+ if row is None:
107
+ return None
108
+ return f"event: updated\ndata: {summary_out(row).model_dump_json()}\n\n"
109
+
110
+
111
+ async def event_stream(
112
+ queue: asyncio.Queue[RegistryEvent | None],
113
+ format_message: Callable[[RegistryEvent], str | None],
114
+ is_disconnected: Callable[[], Awaitable[bool]],
115
+ keep_alive_s: float,
116
+ ) -> AsyncIterator[str]:
117
+ """Server-sent-event messages for queued events, with a keep-alive comment when idle."""
118
+ yield ": connected\n\n"
119
+ while not await is_disconnected():
120
+ try:
121
+ event = await asyncio.wait_for(queue.get(), timeout=keep_alive_s)
122
+ except TimeoutError:
123
+ yield ": keep-alive\n\n"
124
+ continue
125
+ if event is None:
126
+ return
127
+ message = format_message(event)
128
+ if message is not None:
129
+ yield message
130
+
131
+
132
+ def static_file(static_dir: Path, path: str) -> Path | None:
133
+ """The existing file under `static_dir` that `path` names, or `None`; never resolves outside `static_dir`."""
134
+ if not path:
135
+ return None
136
+ root = static_dir.resolve()
137
+ candidate = (root / path).resolve()
138
+ return candidate if candidate.is_relative_to(root) and candidate.is_file() else None
139
+
140
+
141
+ def wants_session_json(request: Request) -> bool:
142
+ """Return whether a session page request explicitly asks for JSON."""
143
+ if request.query_params.get("format") == "json":
144
+ return True
145
+ for item in request.headers.get("accept", "").lower().split(","):
146
+ media_type, *parameters = item.split(";")
147
+ if media_type.strip() != "application/json":
148
+ continue
149
+ quality = 1.0
150
+ for parameter in parameters:
151
+ name, _, value = parameter.strip().partition("=")
152
+ if name == "q":
153
+ try:
154
+ quality = float(value)
155
+ except ValueError:
156
+ quality = 0.0
157
+ break
158
+ if 0 < quality <= 1:
159
+ return True
160
+ return False
161
+
162
+
163
+ def session_html(html: str, session_id: str) -> str:
164
+ """Add the session's JSON alternate and an API discovery hint to the SPA shell."""
165
+ url = f"/api/sessions/{quote(session_id, safe='')}"
166
+ hint = "" if "<!-- API discovery: /api -->" in html else "<!-- API discovery: /api -->\n"
167
+ markup = f'<link rel="alternate" type="application/json" href="{url}">\n{hint}'
168
+ if "</head>" in html:
169
+ return html.replace("</head>", f"{markup}</head>", 1)
170
+ return f"{markup}{html}"
171
+
172
+
173
+ def _mark_omitted(node: dict[str, Any], source: Node, depth: int) -> None:
174
+ """Mark omitted descendants on a tree already mapped only to `depth`."""
175
+ node["children_omitted"] = bool(source.children) if depth == 0 else False
176
+ if depth > 0:
177
+ for mapped, child in zip(node["children"], source.children, strict=True):
178
+ _mark_omitted(mapped, child, depth - 1)
179
+
180
+
181
+ def _project_session(session: Session, mtime: float, depth: int | None, fields: str | None) -> JSONResponse:
182
+ """Select documented top-level groups and optionally bound tree depth."""
183
+ selected = _SESSION_FIELDS if fields is None else tuple(part.strip() for part in fields.split(","))
184
+ if not selected or any(part not in _SESSION_FIELDS for part in selected):
185
+ raise HTTPException(status_code=422, detail="fields must contain: session, diagnostics, tree")
186
+ projected: dict[str, Any] = {}
187
+ if "session" in selected:
188
+ projected.update(
189
+ id=session.id,
190
+ agent=session.agent,
191
+ title=session.title,
192
+ workspace=session.workspace,
193
+ mtime=mtime,
194
+ sources=session.sources,
195
+ )
196
+ if "diagnostics" in selected:
197
+ projected["diagnostics"] = diagnostics_out(session.diagnostics).model_dump(mode="json")
198
+ if "tree" in selected:
199
+ root = node_out(
200
+ session.root,
201
+ agent_ids(session.root),
202
+ frontier=session.root.end.value,
203
+ max_depth=depth,
204
+ activity_flags=activity_flags(session.root, session.root.end.value) if depth is not None else None,
205
+ ).model_dump(mode="json")
206
+ if depth is not None:
207
+ _mark_omitted(root, session.root, depth)
208
+ projected["root"] = root
209
+ return JSONResponse(projected)
210
+
211
+
212
+ def _sorted_findings(root: Node) -> list[FindingOut]:
213
+ """Flatten attached findings and order known avoidable USD, severity, then event time."""
214
+ attached: list[tuple[FindingOut, float | None]] = []
215
+ for node in iter_nodes(root):
216
+ for finding in node.findings:
217
+ observed = finding.evidence.get("call_start_ms")
218
+ time = (
219
+ float(observed)
220
+ if isinstance(observed, (int, float)) and not isinstance(observed, bool)
221
+ else node.start.value
222
+ )
223
+ attached.append((finding_out(finding), time))
224
+
225
+ def order(item: tuple[FindingOut, float | None]) -> tuple[bool, float, int, bool, float, str, str]:
226
+ finding, time = item
227
+ usd = finding.estimated_avoidable_cost.usd
228
+ return (
229
+ usd is None,
230
+ -usd if usd is not None else 0.0,
231
+ _SEVERITY_ORDER.get(finding.severity, len(_SEVERITY_ORDER)),
232
+ time is None,
233
+ time if time is not None else 0.0,
234
+ finding.heuristic_id,
235
+ finding.node_id,
236
+ )
237
+
238
+ return [finding for finding, _ in sorted(attached, key=order)]
239
+
240
+
241
+ def create_app(registry: Registry, static_dir: Path = _STATIC_DIR, pricing: PriceTable | None = None) -> FastAPI:
242
+ """Build the application with the effective pricing table and built frontend."""
243
+ broadcaster = Broadcaster()
244
+ price_table = pricing if pricing is not None else PriceTable.load()
245
+
246
+ @asynccontextmanager
247
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
248
+ broadcaster.bind(asyncio.get_running_loop())
249
+ unsubscribe = registry.subscribe(broadcaster.publish)
250
+ yield
251
+ unsubscribe()
252
+
253
+ app = FastAPI(title="agentprof", lifespan=lifespan)
254
+ app.state.end_streams = broadcaster.end_streams
255
+
256
+ @app.get("/api/", include_in_schema=False)
257
+ @app.get("/api", summary="Discover the public API")
258
+ def api_index() -> ApiIndexOut:
259
+ endpoints: list[ApiEndpointOut] = []
260
+ for path, methods in app.openapi()["paths"].items():
261
+ if not path.startswith("/api"):
262
+ continue
263
+ for method, operation in methods.items():
264
+ if method not in {"get", "post", "put", "patch", "delete"}:
265
+ continue
266
+ endpoints.append(
267
+ ApiEndpointOut(
268
+ method=method.upper(),
269
+ path=path,
270
+ parameters=[parameter["name"] for parameter in operation.get("parameters", [])],
271
+ description=operation.get("summary") or operation.get("description") or path,
272
+ )
273
+ )
274
+ endpoints.sort(key=lambda endpoint: (endpoint.path, endpoint.method))
275
+ return ApiIndexOut(endpoints=endpoints)
276
+
277
+ @app.get("/api/sessions")
278
+ def list_sessions() -> list[SummaryOut]:
279
+ return [summary_out(row) for row in registry.rows()]
280
+
281
+ @app.get("/api/pricing", summary="Inspect the effective price table")
282
+ def get_pricing() -> PricingOut:
283
+ return pricing_out(price_table)
284
+
285
+ @app.get("/api/sessions/events")
286
+ async def session_events(request: Request) -> StreamingResponse:
287
+ queue = broadcaster.open()
288
+
289
+ async def stream() -> AsyncIterator[str]:
290
+ try:
291
+ messages = event_stream(
292
+ queue, lambda event: format_event(event, registry), request.is_disconnected, _KEEP_ALIVE_S
293
+ )
294
+ async for message in messages:
295
+ yield message
296
+ finally:
297
+ broadcaster.close(queue)
298
+
299
+ return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
300
+
301
+ @app.get("/api/sessions/{session_id}/summary", summary="Summarize a session by agent")
302
+ def get_agent_summary(session_id: str) -> AgentSummaryOut:
303
+ try:
304
+ session, mtime = registry.session(session_id)
305
+ except KeyError as error:
306
+ raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
307
+ except SessionUnavailable as error:
308
+ raise HTTPException(status_code=422, detail=str(error)) from error
309
+ return agent_summary_out(session, mtime)
310
+
311
+ @app.get("/api/sessions/{session_id}/diagnostics", summary="Inspect session parse diagnostics")
312
+ def get_diagnostics(session_id: str) -> DiagnosticsDetailOut:
313
+ try:
314
+ session, _ = registry.session(session_id)
315
+ except KeyError as error:
316
+ raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
317
+ except SessionUnavailable as error:
318
+ raise HTTPException(status_code=422, detail=str(error)) from error
319
+ return diagnostics_detail_out(session.diagnostics)
320
+
321
+ @app.get("/api/sessions/{session_id}/findings", summary="List a session's findings by estimated cost and severity")
322
+ def get_findings(session_id: str) -> list[FindingOut]:
323
+ try:
324
+ session, _ = registry.session(session_id)
325
+ except KeyError as error:
326
+ raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
327
+ except SessionUnavailable as error:
328
+ raise HTTPException(status_code=422, detail=str(error)) from error
329
+ return _sorted_findings(session.root)
330
+
331
+ @app.get("/api/sessions/{session_id}", response_model=SessionOut | ProjectedSessionOut)
332
+ def get_session(
333
+ session_id: str,
334
+ depth: Annotated[
335
+ int | None,
336
+ Query(
337
+ ge=0,
338
+ description="Maximum depth from root (root is 0); truncated nodes report children_omitted.",
339
+ ),
340
+ ] = None,
341
+ fields: Annotated[
342
+ str | None,
343
+ Query(description="Groups: session (identity and sources), diagnostics, tree (root); comma-separated."),
344
+ ] = None,
345
+ ) -> SessionOut | JSONResponse:
346
+ try:
347
+ session, mtime = registry.session(session_id)
348
+ except KeyError as error:
349
+ raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
350
+ except SessionUnavailable as error:
351
+ raise HTTPException(status_code=422, detail=str(error)) from error
352
+ return (
353
+ session_out(session, mtime)
354
+ if depth is None and fields is None
355
+ else _project_session(session, mtime, depth, fields)
356
+ )
357
+
358
+ @app.get("/api/sessions/{session_id}/nodes/{node_id}")
359
+ def get_node(session_id: str, node_id: str) -> NodeDetailOut:
360
+ try:
361
+ session, _ = registry.session(session_id)
362
+ except KeyError as error:
363
+ raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
364
+ except SessionUnavailable as error:
365
+ raise HTTPException(status_code=422, detail=str(error)) from error
366
+ node = next((node for node in iter_nodes(session.root) if node.node_id == node_id), None)
367
+ if node is None:
368
+ raise HTTPException(status_code=404, detail=f"unknown node {node_id!r}")
369
+ return node_detail_out(node)
370
+
371
+ index_file = static_dir / "index.html"
372
+
373
+ @app.get("/sessions/{session_id}", include_in_schema=False, response_model=None)
374
+ def session_page(session_id: str, request: Request) -> SessionOut | JSONResponse | HTMLResponse:
375
+ if wants_session_json(request):
376
+ return get_session(session_id)
377
+ html = index_file.read_text(encoding="utf-8") if index_file.is_file() else _PLACEHOLDER_PAGE
378
+ return HTMLResponse(session_html(html, session_id), headers=_NO_CACHE)
379
+
380
+ if index_file.is_file():
381
+
382
+ @app.get("/{path:path}", include_in_schema=False)
383
+ def frontend(path: str) -> FileResponse:
384
+ if path == "api" or path.startswith("api/"):
385
+ raise HTTPException(status_code=404, detail=f"unknown API path /{path}")
386
+ file = static_file(static_dir, path)
387
+ if file is not None:
388
+ return FileResponse(file, headers=_IMMUTABLE if path.startswith(_ASSETS_PREFIX) else _NO_CACHE)
389
+ if not path:
390
+ return FileResponse(index_file, headers=_NO_CACHE)
391
+ raise HTTPException(status_code=404, detail=f"unknown path /{path}")
392
+
393
+ else:
394
+
395
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
396
+ def index() -> str:
397
+ return _PLACEHOLDER_PAGE
398
+
399
+ return app
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Write the API's OpenAPI schema; the frontend generates its TypeScript types from it.
4
+
5
+ Run: `uv run python -m agentprof.server.openapi OUTPUT.json`
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from agentprof.registry import Registry
14
+ from agentprof.server.app import create_app
15
+
16
+
17
+ def openapi_schema() -> dict[str, Any]:
18
+ return create_app(Registry([])).openapi()
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ args = sys.argv[1:] if argv is None else argv
23
+ if len(args) != 1:
24
+ print("usage: python -m agentprof.server.openapi OUTPUT.json", file=sys.stderr)
25
+ return 2
26
+ Path(args[0]).write_text(json.dumps(openapi_schema(), indent=2) + "\n", encoding="utf-8")
27
+ return 0
28
+
29
+
30
+ if __name__ == "__main__":
31
+ sys.exit(main())
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Public response schema for the effective price table."""
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from agentprof.pricing import PriceTable
8
+
9
+
10
+ class ModelPriceOut(BaseModel):
11
+ """USD rate per million tokens for each token kind."""
12
+
13
+ input: float
14
+ output: float
15
+ cache_read: float
16
+ cache_write_5m: float
17
+ cache_write_1h: float
18
+
19
+
20
+ class PricingOut(BaseModel):
21
+ """The configured price table and its model matching rule."""
22
+
23
+ source: str | None
24
+ date: str | None
25
+ unit: str
26
+ usd_per_credit: float
27
+ matching_rule: str
28
+ models: dict[str, ModelPriceOut]
29
+
30
+
31
+ def pricing_out(table: PriceTable) -> PricingOut:
32
+ """Expose the rates actually used by adapters and the configured metadata."""
33
+ return PricingOut(
34
+ source=table.source,
35
+ date=table.date,
36
+ unit="USD per million tokens",
37
+ usd_per_credit=table.usd_per_credit,
38
+ matching_rule="longest_model_prefix",
39
+ models={prefix: ModelPriceOut(**vars(price)) for prefix, price in table.models.items()},
40
+ )