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/benchmark/dataset.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from app.benchmark.models import BenchmarkTask
|
|
8
|
+
|
|
9
|
+
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
|
|
10
|
+
VALID_SCORERS = {"exact", "contains", "json_equal"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bundled_dataset_path() -> Path:
|
|
14
|
+
return Path(__file__).with_name("data") / "starter-v1.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_dataset(path: str | Path | None = None) -> tuple[dict[str, Any], list[BenchmarkTask]]:
|
|
18
|
+
source = Path(path) if path else bundled_dataset_path()
|
|
19
|
+
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
20
|
+
errors = validate_dataset(payload)
|
|
21
|
+
if errors:
|
|
22
|
+
raise ValueError("; ".join(errors))
|
|
23
|
+
tasks = [BenchmarkTask(**item) for item in payload["tasks"]]
|
|
24
|
+
return payload, tasks
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_dataset(payload: Any) -> list[str]:
|
|
28
|
+
if not isinstance(payload, dict):
|
|
29
|
+
return ["dataset must be a JSON object"]
|
|
30
|
+
errors: list[str] = []
|
|
31
|
+
if not isinstance(payload.get("version"), str) or not payload["version"].strip():
|
|
32
|
+
errors.append("version must be a non-empty string")
|
|
33
|
+
tasks = payload.get("tasks")
|
|
34
|
+
if not isinstance(tasks, list) or not tasks:
|
|
35
|
+
return errors + ["tasks must be a non-empty array"]
|
|
36
|
+
seen: set[str] = set()
|
|
37
|
+
for index, task in enumerate(tasks):
|
|
38
|
+
prefix = f"tasks[{index}]"
|
|
39
|
+
if not isinstance(task, dict):
|
|
40
|
+
errors.append(f"{prefix} must be an object")
|
|
41
|
+
continue
|
|
42
|
+
task_id = task.get("id")
|
|
43
|
+
if not isinstance(task_id, str) or not task_id:
|
|
44
|
+
errors.append(f"{prefix}.id must be a non-empty string")
|
|
45
|
+
elif task_id in seen:
|
|
46
|
+
errors.append(f"duplicate task id: {task_id}")
|
|
47
|
+
else:
|
|
48
|
+
seen.add(task_id)
|
|
49
|
+
if task.get("difficulty") not in VALID_DIFFICULTIES:
|
|
50
|
+
errors.append(f"{prefix}.difficulty must be easy, medium, or hard")
|
|
51
|
+
if not isinstance(task.get("prompt"), str) or not task["prompt"]:
|
|
52
|
+
errors.append(f"{prefix}.prompt must be a non-empty string")
|
|
53
|
+
scorer = str(task.get("scorer") or "exact")
|
|
54
|
+
if scorer not in VALID_SCORERS and not scorer.startswith("python:"):
|
|
55
|
+
errors.append(f"{prefix}.scorer is unsupported: {scorer}")
|
|
56
|
+
if "expected" not in task:
|
|
57
|
+
errors.append(f"{prefix}.expected is required")
|
|
58
|
+
return errors
|
|
59
|
+
|
app/benchmark/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class BenchmarkTask:
|
|
9
|
+
id: str
|
|
10
|
+
difficulty: str
|
|
11
|
+
prompt: str
|
|
12
|
+
expected: Any
|
|
13
|
+
scorer: str = "exact"
|
|
14
|
+
tags: list[str] = field(default_factory=list)
|
|
15
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class AdapterResult:
|
|
20
|
+
output: str
|
|
21
|
+
input_tokens: int = 0
|
|
22
|
+
output_tokens: int = 0
|
|
23
|
+
cost_micros: int = 0
|
|
24
|
+
error: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class Observation:
|
|
29
|
+
run_id: str
|
|
30
|
+
candidate: str
|
|
31
|
+
task_id: str
|
|
32
|
+
difficulty: str
|
|
33
|
+
repetition: int
|
|
34
|
+
seed: int
|
|
35
|
+
score: float
|
|
36
|
+
duration_ms: int
|
|
37
|
+
input_tokens: int
|
|
38
|
+
output_tokens: int
|
|
39
|
+
cost_micros: int
|
|
40
|
+
output_hash: str
|
|
41
|
+
error: str | None = None
|
|
42
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
app/benchmark/runner.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from app.benchmark.adapters import BenchmarkAdapter
|
|
9
|
+
from app.benchmark.models import BenchmarkTask, Observation
|
|
10
|
+
from app.benchmark.scorers import score_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class RunLimits:
|
|
15
|
+
repetitions: int = 1
|
|
16
|
+
seed: int = 0
|
|
17
|
+
timeout_seconds: float = 30.0
|
|
18
|
+
token_budget: int = 0
|
|
19
|
+
cost_budget_micros: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_benchmark(
|
|
23
|
+
tasks: list[BenchmarkTask],
|
|
24
|
+
adapter: BenchmarkAdapter,
|
|
25
|
+
candidate: str,
|
|
26
|
+
limits: RunLimits,
|
|
27
|
+
) -> list[Observation]:
|
|
28
|
+
run_id = f"bench_{uuid.uuid4().hex[:24]}"
|
|
29
|
+
observations: list[Observation] = []
|
|
30
|
+
used_tokens = 0
|
|
31
|
+
used_cost = 0
|
|
32
|
+
for repetition in range(limits.repetitions):
|
|
33
|
+
for task in tasks:
|
|
34
|
+
if limits.token_budget and used_tokens >= limits.token_budget:
|
|
35
|
+
return observations
|
|
36
|
+
if limits.cost_budget_micros and used_cost >= limits.cost_budget_micros:
|
|
37
|
+
return observations
|
|
38
|
+
seed = limits.seed + repetition
|
|
39
|
+
started = time.perf_counter_ns()
|
|
40
|
+
result = adapter.execute(task, seed, limits.timeout_seconds)
|
|
41
|
+
duration_ms = max(0, int((time.perf_counter_ns() - started) / 1_000_000))
|
|
42
|
+
score = 0.0 if result.error else score_output(task.scorer, result.output, task.expected)
|
|
43
|
+
used_tokens += result.input_tokens + result.output_tokens
|
|
44
|
+
used_cost += result.cost_micros
|
|
45
|
+
observations.append(
|
|
46
|
+
Observation(
|
|
47
|
+
run_id=run_id,
|
|
48
|
+
candidate=candidate,
|
|
49
|
+
task_id=task.id,
|
|
50
|
+
difficulty=task.difficulty,
|
|
51
|
+
repetition=repetition,
|
|
52
|
+
seed=seed,
|
|
53
|
+
score=score,
|
|
54
|
+
duration_ms=duration_ms,
|
|
55
|
+
input_tokens=result.input_tokens,
|
|
56
|
+
output_tokens=result.output_tokens,
|
|
57
|
+
cost_micros=result.cost_micros,
|
|
58
|
+
output_hash=hashlib.sha256(result.output.encode("utf-8")).hexdigest(),
|
|
59
|
+
error=result.error,
|
|
60
|
+
metadata={"dataset.tags": task.tags},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return observations
|
app/benchmark/scorers.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def score_output(scorer: str, output: str, expected: Any) -> float:
|
|
10
|
+
if scorer == "exact":
|
|
11
|
+
return float(output.strip() == str(expected).strip())
|
|
12
|
+
if scorer == "contains":
|
|
13
|
+
return float(str(expected).strip().lower() in output.strip().lower())
|
|
14
|
+
if scorer == "json_equal":
|
|
15
|
+
try:
|
|
16
|
+
return float(json.loads(output) == expected)
|
|
17
|
+
except (json.JSONDecodeError, TypeError):
|
|
18
|
+
return 0.0
|
|
19
|
+
if scorer.startswith("python:"):
|
|
20
|
+
function = _load_custom_scorer(scorer.removeprefix("python:"))
|
|
21
|
+
value = float(function(output, expected))
|
|
22
|
+
return min(1.0, max(0.0, value))
|
|
23
|
+
raise ValueError(f"Unsupported scorer: {scorer}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_custom_scorer(reference: str) -> Callable[[str, Any], float]:
|
|
27
|
+
module_name, separator, function_name = reference.partition(":")
|
|
28
|
+
if not separator:
|
|
29
|
+
raise ValueError("Custom scorer must use python:module:function")
|
|
30
|
+
function = getattr(importlib.import_module(module_name), function_name, None)
|
|
31
|
+
if not callable(function):
|
|
32
|
+
raise ValueError(f"Custom scorer is not callable: {reference}")
|
|
33
|
+
return function
|
|
34
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from statistics import mean
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def paired_bootstrap(
|
|
10
|
+
baseline: list[dict[str, Any]],
|
|
11
|
+
candidate: list[dict[str, Any]],
|
|
12
|
+
*,
|
|
13
|
+
samples: int = 2000,
|
|
14
|
+
seed: int = 0,
|
|
15
|
+
min_pairs: int = 10,
|
|
16
|
+
regression_threshold: float = 0.0,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
left = _scores_by_key(baseline)
|
|
19
|
+
right = _scores_by_key(candidate)
|
|
20
|
+
keys = sorted(set(left) & set(right))
|
|
21
|
+
differences = [right[key] - left[key] for key in keys]
|
|
22
|
+
result: dict[str, Any] = {
|
|
23
|
+
"schema_version": "benchmark.compare.v1",
|
|
24
|
+
"pairs": len(differences),
|
|
25
|
+
"baseline_mean": mean(left[key] for key in keys) if keys else None,
|
|
26
|
+
"candidate_mean": mean(right[key] for key in keys) if keys else None,
|
|
27
|
+
"delta": mean(differences) if differences else None,
|
|
28
|
+
"confidence": 0.95,
|
|
29
|
+
"threshold": regression_threshold,
|
|
30
|
+
}
|
|
31
|
+
if len(differences) < min_pairs:
|
|
32
|
+
return {**result, "ci_low": None, "ci_high": None, "verdict": "inconclusive"}
|
|
33
|
+
rng = random.Random(seed)
|
|
34
|
+
bootstrapped = sorted(
|
|
35
|
+
mean(differences[rng.randrange(len(differences))] for _ in differences)
|
|
36
|
+
for _ in range(samples)
|
|
37
|
+
)
|
|
38
|
+
low = bootstrapped[int(samples * 0.025)]
|
|
39
|
+
high = bootstrapped[min(samples - 1, int(samples * 0.975))]
|
|
40
|
+
verdict = "regression" if high < -regression_threshold else "no_regression"
|
|
41
|
+
return {**result, "ci_low": low, "ci_high": high, "verdict": verdict}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _scores_by_key(rows: list[dict[str, Any]]) -> dict[tuple[str, int], float]:
|
|
45
|
+
grouped: dict[tuple[str, int], list[float]] = defaultdict(list)
|
|
46
|
+
for row in rows:
|
|
47
|
+
grouped[(str(row["task_id"]), int(row.get("repetition", 0)))].append(float(row["score"]))
|
|
48
|
+
return {key: mean(values) for key, values in grouped.items()}
|
app/benchmark/storage.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from app.benchmark.models import Observation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_observations(rows: list[Observation], path: str | Path) -> Path:
|
|
11
|
+
destination = Path(path)
|
|
12
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
payload = [row.to_dict() for row in rows]
|
|
14
|
+
if destination.suffix.lower() == ".parquet":
|
|
15
|
+
try:
|
|
16
|
+
import pyarrow as pa
|
|
17
|
+
import pyarrow.parquet as pq
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
"Parquet output requires: pip install 'agent-loop-guard-runtime[bench]'"
|
|
21
|
+
) from exc
|
|
22
|
+
pq.write_table(pa.Table.from_pylist(payload), destination)
|
|
23
|
+
else:
|
|
24
|
+
destination.write_text(
|
|
25
|
+
"".join(json.dumps(row, sort_keys=True) + "\n" for row in payload),
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
return destination
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_observations(path: str | Path) -> list[dict[str, Any]]:
|
|
32
|
+
source = Path(path)
|
|
33
|
+
if source.suffix.lower() == ".parquet":
|
|
34
|
+
try:
|
|
35
|
+
import pyarrow.parquet as pq
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
"Parquet input requires: pip install 'agent-loop-guard-runtime[bench]'"
|
|
39
|
+
) from exc
|
|
40
|
+
return pq.read_table(source).to_pylist()
|
|
41
|
+
return [json.loads(line) for line in source.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def summarize_with_duckdb(path: str | Path) -> list[dict[str, Any]]:
|
|
45
|
+
try:
|
|
46
|
+
import duckdb
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"DuckDB analysis requires: pip install 'agent-loop-guard-runtime[bench]'"
|
|
50
|
+
) from exc
|
|
51
|
+
source = str(Path(path).resolve()).replace("'", "''")
|
|
52
|
+
reader = "read_parquet" if str(path).lower().endswith(".parquet") else "read_json_auto"
|
|
53
|
+
connection = duckdb.connect(":memory:")
|
|
54
|
+
rows = connection.execute(
|
|
55
|
+
f"SELECT difficulty, avg(score) AS score, count(*) AS observations "
|
|
56
|
+
f"FROM {reader}('{source}') GROUP BY difficulty ORDER BY difficulty"
|
|
57
|
+
).fetchall()
|
|
58
|
+
return [
|
|
59
|
+
{"difficulty": difficulty, "score": score, "observations": observations}
|
|
60
|
+
for difficulty, score, observations in rows
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def log_to_mlflow(rows: list[Observation], experiment: str = "agent-loop-guard") -> str:
|
|
65
|
+
try:
|
|
66
|
+
import mlflow
|
|
67
|
+
except ImportError as exc:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"MLflow tracking requires: pip install 'agent-loop-guard-runtime[bench]'"
|
|
70
|
+
) from exc
|
|
71
|
+
scores = [row.score for row in rows]
|
|
72
|
+
mlflow.set_experiment(experiment)
|
|
73
|
+
with mlflow.start_run() as run:
|
|
74
|
+
mlflow.log_param("candidate", rows[0].candidate if rows else "unknown")
|
|
75
|
+
mlflow.log_param("observations", len(rows))
|
|
76
|
+
mlflow.log_metric("score", sum(scores) / len(scores) if scores else 0.0)
|
|
77
|
+
mlflow.log_metric("tokens", sum(row.input_tokens + row.output_tokens for row in rows))
|
|
78
|
+
return run.info.run_id
|