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.
- agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
- agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
- app/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/admin_routes.py +179 -0
- app/api/anthropic_routes.py +21 -0
- app/api/common.py +457 -0
- app/api/mcp_routes.py +218 -0
- app/api/openai_routes.py +26 -0
- app/api/replay_routes.py +202 -0
- app/api/ui_routes.py +295 -0
- app/benchmark/__init__.py +2 -0
- app/benchmark/adapters.py +97 -0
- app/benchmark/data/starter-v1.json +38 -0
- app/benchmark/dataset.py +59 -0
- app/benchmark/models.py +46 -0
- app/benchmark/runner.py +63 -0
- app/benchmark/scorers.py +34 -0
- app/benchmark/statistics.py +48 -0
- app/benchmark/storage.py +78 -0
- app/cli.py +624 -0
- app/core/config.py +196 -0
- app/core/demo.py +109 -0
- app/core/loop_detector.py +120 -0
- app/core/policy_engine.py +167 -0
- app/core/redaction.py +84 -0
- app/core/security.py +54 -0
- app/core/token_meter.py +67 -0
- app/db/models.py +296 -0
- app/db/repository.py +1488 -0
- app/db/session.py +57 -0
- app/main.py +59 -0
- app/mcp/__init__.py +1 -0
- app/mcp/gateway.py +230 -0
- app/mcp/policy.py +281 -0
- app/mcp/presets/development.yml +25 -0
- app/mcp/presets/filesystem.yml +18 -0
- app/mcp/stdio.py +142 -0
- app/platform/__init__.py +1 -0
- app/platform/alembic/__init__.py +2 -0
- app/platform/alembic/env.py +38 -0
- app/platform/alembic/script.py.mako +24 -0
- app/platform/alembic/versions/0001_initial_schema.py +18 -0
- app/platform/alembic/versions/__init__.py +2 -0
- app/platform/events.py +44 -0
- app/platform/maintenance.py +138 -0
- app/platform/migrations.py +24 -0
- app/platform/setup.py +92 -0
- app/providers/__init__.py +5 -0
- app/providers/base.py +23 -0
- app/providers/mock.py +169 -0
- app/providers/upstream.py +101 -0
- app/replay/__init__.py +1 -0
- app/replay/costs.py +37 -0
- app/replay/formats.py +87 -0
- app/replay/sdk.py +102 -0
- app/sandbox/__init__.py +2 -0
- app/sandbox/policy.py +22 -0
- app/sandbox/workspace.py +281 -0
- app/static/styles.css +327 -0
- app/templates/agents.html +48 -0
- app/templates/base.html +27 -0
- app/templates/dashboard.html +55 -0
- app/templates/demo.html +36 -0
- app/templates/mcp.html +69 -0
- app/templates/policies.html +30 -0
- app/templates/replay.html +63 -0
- app/templates/replay_compare.html +74 -0
- app/templates/replay_detail.html +130 -0
- app/templates/session_detail.html +71 -0
- app/templates/sessions.html +24 -0
- app/templates/settings.html +20 -0
app/api/replay_routes.py
ADDED
|
@@ -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,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
|
+
}
|