agent-loop-guard-runtime 0.6.0a2__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 (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. app/templates/settings.html +20 -0
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Query
6
+ from fastapi.responses import JSONResponse, PlainTextResponse
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.db.repository import (
11
+ Repository,
12
+ trace_event_dict,
13
+ trace_run_dict,
14
+ trace_span_dict,
15
+ )
16
+ from app.db.session import get_db
17
+ from app.replay.formats import trace_to_jsonl, trace_to_otel
18
+
19
+ router = APIRouter(prefix="/api/v1")
20
+
21
+
22
+ class TraceEventPayload(BaseModel):
23
+ event_id: str | None = None
24
+ span_id: str | None = None
25
+ name: str = "event"
26
+ severity: str = "info"
27
+ timestamp_ns: int | None = None
28
+ attributes: dict[str, Any] = Field(default_factory=dict)
29
+
30
+
31
+ class TraceSpanPayload(BaseModel):
32
+ span_id: str | None = None
33
+ trace_id: str | None = None
34
+ parent_span_id: str | None = None
35
+ name: str = "span"
36
+ status: str = "ok"
37
+ start_ns: int | None = None
38
+ end_ns: int | None = None
39
+ duration_ms: int = 0
40
+ attributes: dict[str, Any] = Field(default_factory=dict)
41
+ events: list[TraceEventPayload] = Field(default_factory=list)
42
+
43
+
44
+ class TraceCreatePayload(BaseModel):
45
+ trace_id: str | None = None
46
+ project_id: str = "default"
47
+ task_id: str | None = None
48
+ task_fingerprint: str | None = None
49
+ agent_name: str | None = None
50
+ model: str | None = None
51
+ status: str = "running"
52
+ failure_tag: str | None = None
53
+ input_tokens: int = 0
54
+ output_tokens: int = 0
55
+ total_tokens: int = 0
56
+ total_cost_micros: int = 0
57
+ pinned: bool = False
58
+ start_ns: int | None = None
59
+ end_ns: int | None = None
60
+ duration_ms: int = 0
61
+ attributes: dict[str, Any] = Field(default_factory=dict)
62
+ spans: list[TraceSpanPayload] = Field(default_factory=list)
63
+
64
+
65
+ class EventBatchPayload(BaseModel):
66
+ trace_id: str
67
+ events: list[TraceEventPayload]
68
+
69
+
70
+ class ComparePayload(BaseModel):
71
+ left_trace_id: str
72
+ right_trace_id: str
73
+
74
+
75
+ class PinPayload(BaseModel):
76
+ pinned: bool = True
77
+
78
+
79
+ class TraceImportPayload(BaseModel):
80
+ bundle: dict[str, Any]
81
+
82
+
83
+ @router.post("/traces")
84
+ def create_trace(payload: TraceCreatePayload, db: Session = Depends(get_db)) -> dict:
85
+ repo = Repository(db)
86
+ trace_data = payload.model_dump(exclude={"spans"})
87
+ span_starts = [span.start_ns for span in payload.spans if span.start_ns is not None]
88
+ span_ends = [span.end_ns for span in payload.spans if span.end_ns is not None]
89
+ if trace_data.get("start_ns") is None and span_starts:
90
+ trace_data["start_ns"] = min(span_starts)
91
+ if trace_data.get("end_ns") is None and span_ends:
92
+ trace_data["end_ns"] = max(span_ends)
93
+ if not trace_data.get("duration_ms") and span_starts and span_ends:
94
+ trace_data["duration_ms"] = max(0, int((max(span_ends) - min(span_starts)) / 1_000_000))
95
+ run = repo.create_trace_run(trace_data)
96
+ for span_payload in payload.spans:
97
+ span_data = span_payload.model_dump(exclude={"events"})
98
+ span = repo.add_trace_span(run.id, span_data)
99
+ if span_payload.events:
100
+ events = [
101
+ {**event.model_dump(), "span_id": event.span_id or (span.id if span else None)}
102
+ for event in span_payload.events
103
+ ]
104
+ repo.add_trace_events(run.id, events)
105
+ exported = repo.trace_export(run.id)
106
+ if exported is None:
107
+ raise HTTPException(500, "Trace was not created.")
108
+ return exported
109
+
110
+
111
+ @router.post("/spans")
112
+ def create_span(payload: TraceSpanPayload, db: Session = Depends(get_db)) -> dict:
113
+ if not payload.trace_id:
114
+ raise HTTPException(400, "trace_id is required.")
115
+ repo = Repository(db)
116
+ span = repo.add_trace_span(payload.trace_id, payload.model_dump(exclude={"events"}))
117
+ if span is None:
118
+ raise HTTPException(404, "Trace not found.")
119
+ if payload.events:
120
+ repo.add_trace_events(
121
+ payload.trace_id,
122
+ [
123
+ {**event.model_dump(), "span_id": event.span_id or span.id}
124
+ for event in payload.events
125
+ ],
126
+ )
127
+ return trace_span_dict(span)
128
+
129
+
130
+ @router.post("/events/batch")
131
+ def create_events(payload: EventBatchPayload, db: Session = Depends(get_db)) -> dict:
132
+ events = Repository(db).add_trace_events(
133
+ payload.trace_id, [event.model_dump() for event in payload.events]
134
+ )
135
+ if events is None:
136
+ raise HTTPException(404, "Trace not found.")
137
+ return {"events": [trace_event_dict(item) for item in events]}
138
+
139
+
140
+ @router.get("/runs")
141
+ def runs(
142
+ limit: int = 50,
143
+ project_id: str | None = None,
144
+ q: str | None = None,
145
+ db: Session = Depends(get_db),
146
+ ) -> list[dict]:
147
+ return [
148
+ trace_run_dict(item)
149
+ for item in Repository(db).list_trace_runs(limit=limit, project_id=project_id, query=q)
150
+ ]
151
+
152
+
153
+ @router.get("/runs/{trace_id}")
154
+ def run_detail(trace_id: str, db: Session = Depends(get_db)) -> dict:
155
+ exported = Repository(db).trace_export(trace_id)
156
+ if exported is None:
157
+ raise HTTPException(404, "Trace not found.")
158
+ return exported
159
+
160
+
161
+ @router.get("/runs/{trace_id}/export")
162
+ def run_export(
163
+ trace_id: str,
164
+ format: str = Query(default="json", pattern="^(json|jsonl|otel)$"),
165
+ db: Session = Depends(get_db),
166
+ ):
167
+ exported = Repository(db).trace_export(trace_id)
168
+ if exported is None:
169
+ raise HTTPException(404, "Trace not found.")
170
+ if format == "jsonl":
171
+ return PlainTextResponse(trace_to_jsonl(exported), media_type="application/x-ndjson")
172
+ if format == "otel":
173
+ return JSONResponse(trace_to_otel(exported))
174
+ return JSONResponse(exported)
175
+
176
+
177
+ @router.post("/runs/import")
178
+ def import_run(payload: TraceImportPayload, db: Session = Depends(get_db)) -> dict:
179
+ repo = Repository(db)
180
+ try:
181
+ run = repo.import_trace_bundle(payload.bundle)
182
+ except ValueError as exc:
183
+ raise HTTPException(422, str(exc)) from exc
184
+ exported = repo.trace_export(run.id)
185
+ assert exported is not None
186
+ return exported
187
+
188
+
189
+ @router.post("/runs/{trace_id}/pin")
190
+ def pin_run(trace_id: str, payload: PinPayload, db: Session = Depends(get_db)) -> dict:
191
+ run = Repository(db).pin_trace(trace_id, payload.pinned)
192
+ if run is None:
193
+ raise HTTPException(404, "Trace not found.")
194
+ return trace_run_dict(run)
195
+
196
+
197
+ @router.post("/compare")
198
+ def compare(payload: ComparePayload, db: Session = Depends(get_db)) -> dict:
199
+ result = Repository(db).compare_traces(payload.left_trace_id, payload.right_trace_id)
200
+ if result is None:
201
+ raise HTTPException(404, "One or both traces were not found.")
202
+ return result
app/api/ui_routes.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Request
7
+ from fastapi.responses import RedirectResponse
8
+ from fastapi.templating import Jinja2Templates
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.core.demo import run_demo
12
+ from app.db.repository import (
13
+ Repository,
14
+ agent_dict,
15
+ event_dict,
16
+ mcp_approval_dict,
17
+ mcp_event_dict,
18
+ mcp_server_dict,
19
+ policy_dict,
20
+ request_dict,
21
+ session_dict,
22
+ trace_artifact_dict,
23
+ trace_event_dict,
24
+ trace_run_dict,
25
+ trace_span_dict,
26
+ )
27
+ from app.db.session import get_db
28
+
29
+ router = APIRouter()
30
+ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[1] / "templates"))
31
+
32
+
33
+ @router.get("/")
34
+ def dashboard(request: Request, db: Session = Depends(get_db)):
35
+ repo = Repository(db)
36
+ approvals = repo.list_mcp_approvals(pending_only=True)
37
+ return templates.TemplateResponse(
38
+ request,
39
+ "dashboard.html",
40
+ {
41
+ "stats": repo.aggregate_stats(),
42
+ "sessions": [session_dict(item) for item in repo.list_sessions(10)],
43
+ "pending_approvals": len(approvals),
44
+ "docker_available": shutil.which("docker") is not None,
45
+ },
46
+ )
47
+
48
+
49
+ @router.get("/sessions")
50
+ def sessions_page(request: Request, db: Session = Depends(get_db)):
51
+ repo = Repository(db)
52
+ return templates.TemplateResponse(
53
+ request,
54
+ "sessions.html",
55
+ {"sessions": [session_dict(item) for item in repo.list_sessions(100)]},
56
+ )
57
+
58
+
59
+ @router.get("/sessions/{session_id}")
60
+ def session_page(session_id: str, request: Request, db: Session = Depends(get_db)):
61
+ repo = Repository(db)
62
+ session = repo.get_session(session_id)
63
+ if session is None:
64
+ raise HTTPException(404, "Session not found.")
65
+ return templates.TemplateResponse(
66
+ request,
67
+ "session_detail.html",
68
+ {
69
+ "session": session_dict(session),
70
+ "requests": [request_dict(item) for item in repo.requests_for_session(session_id)],
71
+ "events": [event_dict(item) for item in repo.events_for_session(session_id)],
72
+ },
73
+ )
74
+
75
+
76
+ @router.get("/replay")
77
+ def replay_page(
78
+ request: Request,
79
+ q: str | None = None,
80
+ project_id: str | None = None,
81
+ db: Session = Depends(get_db),
82
+ ):
83
+ repo = Repository(db)
84
+ return templates.TemplateResponse(
85
+ request,
86
+ "replay.html",
87
+ {
88
+ "runs": [
89
+ trace_run_dict(item)
90
+ for item in repo.list_trace_runs(limit=100, project_id=project_id, query=q)
91
+ ],
92
+ "q": q or "",
93
+ "project_id": project_id or "",
94
+ },
95
+ )
96
+
97
+
98
+ @router.get("/mcp")
99
+ def mcp_page(request: Request, db: Session = Depends(get_db)):
100
+ repo = Repository(db)
101
+ return templates.TemplateResponse(
102
+ request,
103
+ "mcp.html",
104
+ {
105
+ "servers": [mcp_server_dict(item) for item in repo.list_mcp_servers()],
106
+ "events": [mcp_event_dict(item) for item in repo.list_mcp_events(100)],
107
+ "approvals": [mcp_approval_dict(item) for item in repo.list_mcp_approvals()],
108
+ "policy_path": request.app.state.config.mcp_policy_path,
109
+ },
110
+ )
111
+
112
+
113
+ @router.post("/mcp/approvals/{approval_id}/decision")
114
+ async def mcp_approval_ui(
115
+ approval_id: str, request: Request, db: Session = Depends(get_db)
116
+ ):
117
+ form = await request.form()
118
+ action = str(form.get("action") or "deny")
119
+ scope = str(form.get("scope") or "once")
120
+ Repository(db).decide_mcp_approval(approval_id, action, scope)
121
+ return RedirectResponse("/mcp", status_code=303)
122
+
123
+
124
+ @router.post("/replay/compare")
125
+ async def replay_compare_page(request: Request, db: Session = Depends(get_db)):
126
+ form = await request.form()
127
+ left_id = str(form.get("left_trace_id") or "")
128
+ right_id = str(form.get("right_trace_id") or "")
129
+ result = Repository(db).compare_traces(left_id, right_id)
130
+ if result is None:
131
+ raise HTTPException(404, "One or both traces were not found.")
132
+ return templates.TemplateResponse(
133
+ request,
134
+ "replay_compare.html",
135
+ {"result": result, "left_id": left_id, "right_id": right_id},
136
+ )
137
+
138
+
139
+ @router.get("/replay/{trace_id}")
140
+ def replay_detail_page(trace_id: str, request: Request, db: Session = Depends(get_db)):
141
+ repo = Repository(db)
142
+ run = repo.get_trace_run(trace_id)
143
+ if run is None:
144
+ raise HTTPException(404, "Trace not found.")
145
+ spans = [trace_span_dict(item) for item in repo.trace_spans(trace_id)]
146
+ if spans:
147
+ first_start = min(span["start_ns"] for span in spans)
148
+ last_end = max((span["end_ns"] or span["start_ns"]) for span in spans)
149
+ total = max(1, last_end - first_start)
150
+ parents = {span["id"]: span.get("parent_span_id") for span in spans}
151
+ for span in spans:
152
+ depth = 0
153
+ parent = span.get("parent_span_id")
154
+ while parent and depth < 8:
155
+ depth += 1
156
+ parent = parents.get(parent)
157
+ span["depth"] = depth
158
+ span["offset_pct"] = ((span["start_ns"] - first_start) / total) * 100
159
+ span["width_pct"] = max(1.5, (max(1, span["duration_ms"] * 1_000_000) / total) * 100)
160
+ return templates.TemplateResponse(
161
+ request,
162
+ "replay_detail.html",
163
+ {
164
+ "run": trace_run_dict(run),
165
+ "spans": spans,
166
+ "events": [trace_event_dict(item) for item in repo.trace_events(trace_id)],
167
+ "artifacts": [trace_artifact_dict(item) for item in repo.trace_artifacts(trace_id)],
168
+ },
169
+ )
170
+
171
+
172
+ @router.post("/replay/{trace_id}/pin")
173
+ async def replay_pin_page(trace_id: str, request: Request, db: Session = Depends(get_db)):
174
+ form = await request.form()
175
+ Repository(db).pin_trace(trace_id, str(form.get("pinned") or "true") == "true")
176
+ return RedirectResponse(f"/replay/{trace_id}", status_code=303)
177
+
178
+
179
+ @router.post("/sessions/{session_id}/pause")
180
+ def pause_session_ui(session_id: str, db: Session = Depends(get_db)):
181
+ Repository(db).pause_session(session_id)
182
+ return RedirectResponse(f"/sessions/{session_id}", status_code=303)
183
+
184
+
185
+ @router.post("/sessions/{session_id}/resume")
186
+ def resume_session_ui(session_id: str, db: Session = Depends(get_db)):
187
+ Repository(db).resume_session(session_id)
188
+ return RedirectResponse(f"/sessions/{session_id}", status_code=303)
189
+
190
+
191
+ @router.post("/sessions/{session_id}/edit")
192
+ async def edit_session_ui(session_id: str, request: Request, db: Session = Depends(get_db)):
193
+ form = await request.form()
194
+ Repository(db).update_session(
195
+ session_id,
196
+ name=str(form.get("name") or ""),
197
+ note=str(form.get("note") or ""),
198
+ )
199
+ return RedirectResponse(f"/sessions/{session_id}", status_code=303)
200
+
201
+
202
+ @router.get("/policies")
203
+ def policies_page(request: Request, project_id: str = "default", db: Session = Depends(get_db)):
204
+ repo = Repository(db)
205
+ return templates.TemplateResponse(
206
+ request,
207
+ "policies.html",
208
+ {"project_id": project_id, "policies": [policy_dict(item) for item in repo.policies(project_id)]},
209
+ )
210
+
211
+
212
+ @router.post("/policies/{project_id}/update")
213
+ async def update_policies_ui(project_id: str, request: Request, db: Session = Depends(get_db)):
214
+ form = await request.form()
215
+ rows = []
216
+ for policy in Repository(db).policies(project_id):
217
+ prefix = policy.id
218
+ rows.append(
219
+ {
220
+ "id": policy.id,
221
+ "rule_type": policy.rule_type,
222
+ "threshold": int(form.get(f"{prefix}_threshold") or policy.threshold),
223
+ "window_size": int(form.get(f"{prefix}_window_size") or policy.window_size),
224
+ "action": str(form.get(f"{prefix}_action") or policy.action),
225
+ "enabled": form.get(f"{prefix}_enabled") == "on",
226
+ }
227
+ )
228
+ Repository(db).upsert_policies(project_id, rows)
229
+ return RedirectResponse(f"/policies?project_id={project_id}", status_code=303)
230
+
231
+
232
+ @router.get("/agents")
233
+ def agents_page(request: Request, db: Session = Depends(get_db)):
234
+ repo = Repository(db)
235
+ return templates.TemplateResponse(
236
+ request,
237
+ "agents.html",
238
+ {"agents": [agent_dict(item) for item in repo.list_agents()], "created_key": None},
239
+ )
240
+
241
+
242
+ @router.post("/agents")
243
+ async def create_agent_ui(request: Request, db: Session = Depends(get_db)):
244
+ form = await request.form()
245
+ repo = Repository(db)
246
+ agent, raw_key = repo.create_agent(
247
+ str(form.get("project_id") or "default"),
248
+ str(form.get("name") or "Local agent"),
249
+ str(form.get("protocol") or "openai"),
250
+ )
251
+ return templates.TemplateResponse(
252
+ request,
253
+ "agents.html",
254
+ {"agents": [agent_dict(item) for item in repo.list_agents()], "created_key": raw_key, "created_agent": agent_dict(agent)},
255
+ )
256
+
257
+
258
+ @router.post("/agents/{agent_id}/pause")
259
+ def pause_agent_ui(agent_id: str, db: Session = Depends(get_db)):
260
+ Repository(db).pause_agent(agent_id)
261
+ return RedirectResponse("/agents", status_code=303)
262
+
263
+
264
+ @router.post("/agents/{agent_id}/resume")
265
+ def resume_agent_ui(agent_id: str, db: Session = Depends(get_db)):
266
+ Repository(db).resume_agent(agent_id)
267
+ return RedirectResponse("/agents", status_code=303)
268
+
269
+
270
+ @router.get("/demo")
271
+ def demo_page(request: Request, db: Session = Depends(get_db)):
272
+ repo = Repository(db)
273
+ return templates.TemplateResponse(
274
+ request,
275
+ "demo.html",
276
+ {"sessions": [session_dict(item) for item in repo.list_sessions(10)], "result": None},
277
+ )
278
+
279
+
280
+ @router.post("/demo/run")
281
+ async def demo_run_ui(request: Request, db: Session = Depends(get_db)):
282
+ form = await request.form()
283
+ scenario = str(form.get("scenario") or "exact-loop")
284
+ mode = str(form.get("mode") or "shadow")
285
+ repo = Repository(db)
286
+ agents = repo.list_agents()
287
+ if not agents:
288
+ raise HTTPException(500, "No agent is available.")
289
+ session = run_demo(repo, agents[-1], scenario, mode)
290
+ return RedirectResponse(f"/sessions/{session.id}", status_code=303)
291
+
292
+
293
+ @router.get("/settings")
294
+ def settings_page(request: Request):
295
+ return templates.TemplateResponse(request, "settings.html", {"config": request.app.state.config})
@@ -0,0 +1,2 @@
1
+ """Deterministic local benchmark and regression tools."""
2
+
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ import shlex
6
+ import subprocess
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from app.benchmark.models import AdapterResult, BenchmarkTask
13
+
14
+
15
+ def _tokens(value: str) -> int:
16
+ return max(1, (len(value) + 3) // 4)
17
+
18
+
19
+ class BenchmarkAdapter(ABC):
20
+ @abstractmethod
21
+ def execute(self, task: BenchmarkTask, seed: int, timeout: float) -> AdapterResult:
22
+ raise NotImplementedError
23
+
24
+
25
+ class MockAdapter(BenchmarkAdapter):
26
+ def __init__(self, variant: str = "baseline") -> None:
27
+ self.variant = variant
28
+
29
+ def execute(self, task: BenchmarkTask, seed: int, timeout: float) -> AdapterResult:
30
+ del timeout
31
+ output = json.dumps(task.expected, sort_keys=True) if task.scorer == "json_equal" else str(task.expected)
32
+ if self.variant == "regressed" and task.difficulty in {"medium", "hard"}:
33
+ output = f"incorrect-{random.Random(f'{seed}:{task.id}').randrange(1000)}"
34
+ return AdapterResult(output, _tokens(task.prompt), _tokens(output))
35
+
36
+
37
+ class HTTPAdapter(BenchmarkAdapter):
38
+ def __init__(self, endpoint: str, model: str, api_key: str | None = None) -> None:
39
+ self.endpoint = endpoint
40
+ self.model = model
41
+ self.api_key = api_key
42
+
43
+ def execute(self, task: BenchmarkTask, seed: int, timeout: float) -> AdapterResult:
44
+ headers = {"authorization": f"Bearer {self.api_key}"} if self.api_key else {}
45
+ try:
46
+ response = httpx.post(
47
+ self.endpoint,
48
+ headers=headers,
49
+ json={"model": self.model, "input": task.prompt, "seed": seed},
50
+ timeout=timeout,
51
+ )
52
+ response.raise_for_status()
53
+ payload = response.json()
54
+ output = _response_text(payload)
55
+ usage = payload.get("usage") or {}
56
+ return AdapterResult(
57
+ output=output,
58
+ input_tokens=int(usage.get("input_tokens") or _tokens(task.prompt)),
59
+ output_tokens=int(usage.get("output_tokens") or _tokens(output)),
60
+ )
61
+ except (httpx.HTTPError, ValueError) as exc:
62
+ return AdapterResult("", error=str(exc))
63
+
64
+
65
+ class CLIAdapter(BenchmarkAdapter):
66
+ def __init__(self, command: str | list[str]) -> None:
67
+ self.command = shlex.split(command) if isinstance(command, str) else command
68
+
69
+ def execute(self, task: BenchmarkTask, seed: int, timeout: float) -> AdapterResult:
70
+ try:
71
+ process = subprocess.run(
72
+ self.command,
73
+ input=json.dumps({"prompt": task.prompt, "seed": seed}),
74
+ text=True,
75
+ capture_output=True,
76
+ timeout=timeout,
77
+ check=False,
78
+ )
79
+ except (OSError, subprocess.TimeoutExpired) as exc:
80
+ return AdapterResult("", error=str(exc))
81
+ if process.returncode:
82
+ return AdapterResult("", error=process.stderr.strip() or f"exit {process.returncode}")
83
+ output = process.stdout.strip()
84
+ return AdapterResult(output, _tokens(task.prompt), _tokens(output))
85
+
86
+
87
+ def _response_text(payload: dict[str, Any]) -> str:
88
+ if isinstance(payload.get("output_text"), str):
89
+ return payload["output_text"]
90
+ for item in payload.get("output") or []:
91
+ for content in item.get("content") or []:
92
+ if isinstance(content.get("text"), str):
93
+ return content["text"]
94
+ choices = payload.get("choices") or []
95
+ if choices:
96
+ return str((choices[0].get("message") or {}).get("content") or choices[0].get("text") or "")
97
+ return ""
@@ -0,0 +1,38 @@
1
+ {
2
+ "version": "starter-v1",
3
+ "description": "Thirty deterministic tasks for local regression smoke tests.",
4
+ "tasks": [
5
+ {"id":"easy-01","difficulty":"easy","prompt":"Return exactly: alpha","expected":"alpha","scorer":"exact","tags":["format"]},
6
+ {"id":"easy-02","difficulty":"easy","prompt":"Return exactly: beta","expected":"beta","scorer":"exact","tags":["format"]},
7
+ {"id":"easy-03","difficulty":"easy","prompt":"What is 2 + 2? Return only the number.","expected":"4","scorer":"exact","tags":["math"]},
8
+ {"id":"easy-04","difficulty":"easy","prompt":"Return the lowercase form of HELLO.","expected":"hello","scorer":"exact","tags":["text"]},
9
+ {"id":"easy-05","difficulty":"easy","prompt":"Name the boolean opposite of true.","expected":"false","scorer":"contains","tags":["logic"]},
10
+ {"id":"easy-06","difficulty":"easy","prompt":"Return JSON with ok=true.","expected":{"ok":true},"scorer":"json_equal","tags":["json"]},
11
+ {"id":"easy-07","difficulty":"easy","prompt":"What extension is commonly used for Python files?","expected":".py","scorer":"contains","tags":["coding"]},
12
+ {"id":"easy-08","difficulty":"easy","prompt":"Return exactly three.","expected":"three","scorer":"exact","tags":["format"]},
13
+ {"id":"easy-09","difficulty":"easy","prompt":"Return the first letter of agent.","expected":"a","scorer":"exact","tags":["text"]},
14
+ {"id":"easy-10","difficulty":"easy","prompt":"Return JSON null.","expected":null,"scorer":"json_equal","tags":["json"]},
15
+
16
+ {"id":"medium-01","difficulty":"medium","prompt":"Return the sorted numbers 3,1,2 joined by commas.","expected":"1,2,3","scorer":"exact","tags":["reasoning"]},
17
+ {"id":"medium-02","difficulty":"medium","prompt":"Return the SHA abbreviation as lowercase text.","expected":"sha","scorer":"exact","tags":["coding"]},
18
+ {"id":"medium-03","difficulty":"medium","prompt":"Return JSON array containing 1, 2, 3.","expected":[1,2,3],"scorer":"json_equal","tags":["json"]},
19
+ {"id":"medium-04","difficulty":"medium","prompt":"A command exits with code zero. Include the word success.","expected":"success","scorer":"contains","tags":["shell"]},
20
+ {"id":"medium-05","difficulty":"medium","prompt":"Return 5 factorial as digits only.","expected":"120","scorer":"exact","tags":["math"]},
21
+ {"id":"medium-06","difficulty":"medium","prompt":"Return the normalized path a/c for input a/b/../c.","expected":"a/c","scorer":"exact","tags":["filesystem"]},
22
+ {"id":"medium-07","difficulty":"medium","prompt":"Return JSON with count 3 and valid true.","expected":{"count":3,"valid":true},"scorer":"json_equal","tags":["json"]},
23
+ {"id":"medium-08","difficulty":"medium","prompt":"Which HTTP status means not found? Return digits.","expected":"404","scorer":"exact","tags":["http"]},
24
+ {"id":"medium-09","difficulty":"medium","prompt":"Include the term least privilege in the answer.","expected":"least privilege","scorer":"contains","tags":["security"]},
25
+ {"id":"medium-10","difficulty":"medium","prompt":"Return the result of 0b1010 in decimal.","expected":"10","scorer":"exact","tags":["math"]},
26
+
27
+ {"id":"hard-01","difficulty":"hard","prompt":"Return JSON describing a denied delete: action deny, tool delete_file.","expected":{"action":"deny","tool":"delete_file"},"scorer":"json_equal","tags":["policy"]},
28
+ {"id":"hard-02","difficulty":"hard","prompt":"Return the stable topological order A,B,C for edges A->B and B->C.","expected":"A,B,C","scorer":"exact","tags":["reasoning"]},
29
+ {"id":"hard-03","difficulty":"hard","prompt":"A paired test lacks enough samples. Include inconclusive.","expected":"inconclusive","scorer":"contains","tags":["statistics"]},
30
+ {"id":"hard-04","difficulty":"hard","prompt":"Return JSON with protocol jsonrpc and version 2.0.","expected":{"protocol":"jsonrpc","version":"2.0"},"scorer":"json_equal","tags":["mcp"]},
31
+ {"id":"hard-05","difficulty":"hard","prompt":"Return the hexadecimal SHA-256 digest length in characters.","expected":"64","scorer":"exact","tags":["security"]},
32
+ {"id":"hard-06","difficulty":"hard","prompt":"A sandbox has no network. Include offline and blocked.","expected":"offline","scorer":"contains","tags":["sandbox"]},
33
+ {"id":"hard-07","difficulty":"hard","prompt":"Return JSON with ci_low -0.2 and ci_high -0.1.","expected":{"ci_high":-0.1,"ci_low":-0.2},"scorer":"json_equal","tags":["statistics"]},
34
+ {"id":"hard-08","difficulty":"hard","prompt":"Return the SQL operation that reads rows.","expected":"SELECT","scorer":"exact","tags":["database"]},
35
+ {"id":"hard-09","difficulty":"hard","prompt":"Include both rate and limit in a two-word phrase.","expected":"rate limit","scorer":"contains","tags":["resilience"]},
36
+ {"id":"hard-10","difficulty":"hard","prompt":"Return JSON indicating passed true and score 1.","expected":{"passed":true,"score":1},"scorer":"json_equal","tags":["benchmark"]}
37
+ ]
38
+ }