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,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
+
@@ -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
+
@@ -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
@@ -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()}
@@ -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