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/mcp/stdio.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from app.core.config import AppConfig
|
|
11
|
+
from app.db.repository import Repository
|
|
12
|
+
from app.db.session import build_engine, build_session_factory, init_db
|
|
13
|
+
from app.mcp.gateway import MCPGateway, jsonrpc_error, mock_response
|
|
14
|
+
from app.mcp.policy import MCPPolicyEngine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _write_message(stream: TextIO, message: dict, lock: threading.Lock | None = None) -> None:
|
|
18
|
+
payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
19
|
+
if lock:
|
|
20
|
+
with lock:
|
|
21
|
+
stream.write(payload)
|
|
22
|
+
stream.flush()
|
|
23
|
+
else:
|
|
24
|
+
stream.write(payload)
|
|
25
|
+
stream.flush()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_stdio_proxy(
|
|
29
|
+
command: list[str],
|
|
30
|
+
*,
|
|
31
|
+
policy_path: str | None = None,
|
|
32
|
+
server_id: str = "stdio",
|
|
33
|
+
config: AppConfig | None = None,
|
|
34
|
+
) -> int:
|
|
35
|
+
if not command:
|
|
36
|
+
raise ValueError("An upstream command is required after --.")
|
|
37
|
+
config = config or AppConfig.from_env()
|
|
38
|
+
engine = build_engine(config)
|
|
39
|
+
init_db(engine, config)
|
|
40
|
+
session_factory = build_session_factory(engine)
|
|
41
|
+
process = subprocess.Popen(
|
|
42
|
+
command,
|
|
43
|
+
stdin=subprocess.PIPE,
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
text=True,
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
bufsize=1,
|
|
49
|
+
)
|
|
50
|
+
assert process.stdin and process.stdout and process.stderr
|
|
51
|
+
output_lock = threading.Lock()
|
|
52
|
+
gateway_lock = threading.Lock()
|
|
53
|
+
pending: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
db = session_factory()
|
|
56
|
+
repo = Repository(db)
|
|
57
|
+
repo.ensure_mcp_server(server_id, server_id, "stdio", " ".join(command))
|
|
58
|
+
policy = MCPPolicyEngine(policy_path or config.mcp_policy_path)
|
|
59
|
+
session = repo.start_mcp_session(server_id, client_name="stdio-client", mode=str(policy.data.get("mode", "enforce")))
|
|
60
|
+
gateway = MCPGateway(
|
|
61
|
+
repo,
|
|
62
|
+
policy,
|
|
63
|
+
server_id,
|
|
64
|
+
session_id=session.id,
|
|
65
|
+
approval_timeout_seconds=config.mcp_approval_timeout_seconds,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def upstream_reader() -> None:
|
|
69
|
+
for raw in process.stdout:
|
|
70
|
+
try:
|
|
71
|
+
response = json.loads(raw)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
print("[mcp upstream] ignored non-JSON stdout", file=sys.stderr)
|
|
74
|
+
continue
|
|
75
|
+
request_id = str(response.get("id")) if response.get("id") is not None else None
|
|
76
|
+
with gateway_lock:
|
|
77
|
+
if request_id and pending.pop(request_id, None) == "tools/list":
|
|
78
|
+
response = gateway.filter_tools(response)
|
|
79
|
+
_write_message(sys.stdout, response, output_lock)
|
|
80
|
+
|
|
81
|
+
def stderr_reader() -> None:
|
|
82
|
+
for raw in process.stderr:
|
|
83
|
+
sys.stderr.write(raw)
|
|
84
|
+
sys.stderr.flush()
|
|
85
|
+
|
|
86
|
+
threads = [
|
|
87
|
+
threading.Thread(target=upstream_reader, daemon=True),
|
|
88
|
+
threading.Thread(target=stderr_reader, daemon=True),
|
|
89
|
+
]
|
|
90
|
+
for thread in threads:
|
|
91
|
+
thread.start()
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
for raw in sys.stdin:
|
|
95
|
+
try:
|
|
96
|
+
message = json.loads(raw)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
_write_message(sys.stdout, jsonrpc_error(None, -32700, "Parse error"), output_lock)
|
|
99
|
+
continue
|
|
100
|
+
with gateway_lock:
|
|
101
|
+
interception = gateway.intercept(message)
|
|
102
|
+
if not interception.forward:
|
|
103
|
+
if interception.response:
|
|
104
|
+
_write_message(sys.stdout, interception.response, output_lock)
|
|
105
|
+
continue
|
|
106
|
+
request_id = interception.message.get("id")
|
|
107
|
+
if request_id is not None:
|
|
108
|
+
pending[str(request_id)] = str(interception.message.get("method") or "")
|
|
109
|
+
_write_message(process.stdin, interception.message)
|
|
110
|
+
finally:
|
|
111
|
+
if not process.stdin.closed:
|
|
112
|
+
process.stdin.close()
|
|
113
|
+
try:
|
|
114
|
+
process.wait(timeout=5)
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
process.terminate()
|
|
117
|
+
process.wait(timeout=5)
|
|
118
|
+
for thread in threads:
|
|
119
|
+
thread.join(timeout=2)
|
|
120
|
+
repo.end_mcp_session(session.id)
|
|
121
|
+
db.close()
|
|
122
|
+
return int(process.returncode or 0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_mock_stdio_server() -> int:
|
|
126
|
+
for raw in sys.stdin:
|
|
127
|
+
try:
|
|
128
|
+
message = json.loads(raw)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
_write_message(sys.stdout, jsonrpc_error(None, -32700, "Parse error"))
|
|
131
|
+
continue
|
|
132
|
+
response = mock_response(message)
|
|
133
|
+
if response is not None:
|
|
134
|
+
_write_message(sys.stdout, response)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_policy_file(path: str | Path) -> list[str]:
|
|
139
|
+
from app.mcp.policy import load_policy, validate_policy
|
|
140
|
+
|
|
141
|
+
data, _ = load_policy(path)
|
|
142
|
+
return validate_policy(data)
|
app/platform/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared platform services used by every Agent Loop Guard module."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
from sqlalchemy import engine_from_config, pool
|
|
5
|
+
|
|
6
|
+
from app.db.models import Base
|
|
7
|
+
|
|
8
|
+
config = context.config
|
|
9
|
+
target_metadata = Base.metadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_migrations_offline() -> None:
|
|
13
|
+
context.configure(
|
|
14
|
+
url=config.get_main_option("sqlalchemy.url"),
|
|
15
|
+
target_metadata=target_metadata,
|
|
16
|
+
literal_binds=True,
|
|
17
|
+
compare_type=True,
|
|
18
|
+
)
|
|
19
|
+
with context.begin_transaction():
|
|
20
|
+
context.run_migrations()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_migrations_online() -> None:
|
|
24
|
+
connectable = engine_from_config(
|
|
25
|
+
config.get_section(config.config_ini_section) or {},
|
|
26
|
+
prefix="sqlalchemy.",
|
|
27
|
+
poolclass=pool.NullPool,
|
|
28
|
+
)
|
|
29
|
+
with connectable.connect() as connection:
|
|
30
|
+
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
|
31
|
+
with context.begin_transaction():
|
|
32
|
+
context.run_migrations()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if context.is_offline_mode():
|
|
36
|
+
run_migrations_offline()
|
|
37
|
+
else:
|
|
38
|
+
run_migrations_online()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
from typing import Sequence, Union
|
|
8
|
+
|
|
9
|
+
from alembic import op
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
${imports if imports else ""}
|
|
12
|
+
|
|
13
|
+
revision: str = ${repr(up_revision)}
|
|
14
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
15
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
16
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade() -> None:
|
|
24
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Baseline the local Agent Loop Guard schema."""
|
|
2
|
+
|
|
3
|
+
from alembic import op
|
|
4
|
+
|
|
5
|
+
from app.db.models import Base
|
|
6
|
+
|
|
7
|
+
revision = "0001_initial"
|
|
8
|
+
down_revision = None
|
|
9
|
+
branch_labels = None
|
|
10
|
+
depends_on = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def upgrade() -> None:
|
|
14
|
+
Base.metadata.create_all(bind=op.get_bind())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def downgrade() -> None:
|
|
18
|
+
Base.metadata.drop_all(bind=op.get_bind())
|
app/platform/events.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from app.core.redaction import redact_value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class EventEnvelope:
|
|
13
|
+
"""Stable, redacted event contract shared by all local modules."""
|
|
14
|
+
|
|
15
|
+
source: str
|
|
16
|
+
type: str
|
|
17
|
+
project_id: str = "default"
|
|
18
|
+
trace_id: str | None = None
|
|
19
|
+
span_id: str | None = None
|
|
20
|
+
severity: str = "info"
|
|
21
|
+
timestamp_ns: int = field(default_factory=time.time_ns)
|
|
22
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
event_id: str = field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:24]}")
|
|
24
|
+
schema_version: str = "event.v1"
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
payload = asdict(self)
|
|
28
|
+
payload["attributes"] = redact_value(self.attributes)
|
|
29
|
+
return payload
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, payload: dict[str, Any]) -> EventEnvelope:
|
|
33
|
+
return cls(
|
|
34
|
+
event_id=str(payload.get("event_id") or f"evt_{uuid.uuid4().hex[:24]}"),
|
|
35
|
+
source=str(payload.get("source") or "unknown"),
|
|
36
|
+
type=str(payload.get("type") or "event"),
|
|
37
|
+
project_id=str(payload.get("project_id") or "default"),
|
|
38
|
+
trace_id=payload.get("trace_id"),
|
|
39
|
+
span_id=payload.get("span_id"),
|
|
40
|
+
severity=str(payload.get("severity") or "info"),
|
|
41
|
+
timestamp_ns=int(payload.get("timestamp_ns") or time.time_ns()),
|
|
42
|
+
attributes=dict(payload.get("attributes") or {}),
|
|
43
|
+
schema_version=str(payload.get("schema_version") or "event.v1"),
|
|
44
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import zipfile
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from sqlalchemy import delete, select, update
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
from app.core.config import AppConfig
|
|
17
|
+
from app.db.models import (
|
|
18
|
+
Event,
|
|
19
|
+
GuardSession,
|
|
20
|
+
RequestRecord,
|
|
21
|
+
ToolCall,
|
|
22
|
+
TraceArtifact,
|
|
23
|
+
TraceEvent,
|
|
24
|
+
TraceRun,
|
|
25
|
+
TraceSpan,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sqlite_path(config: AppConfig) -> Path | None:
|
|
30
|
+
if not config.storage_url.startswith("sqlite:///"):
|
|
31
|
+
return None
|
|
32
|
+
raw = config.storage_url.removeprefix("sqlite:///")
|
|
33
|
+
if raw == ":memory:":
|
|
34
|
+
return None
|
|
35
|
+
path = Path(raw)
|
|
36
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_doctor_report(config: AppConfig) -> dict[str, Any]:
|
|
40
|
+
checks: list[dict[str, Any]] = []
|
|
41
|
+
|
|
42
|
+
def add(name: str, ok: bool, detail: str) -> None:
|
|
43
|
+
checks.append({"name": name, "ok": ok, "detail": detail})
|
|
44
|
+
|
|
45
|
+
add("python", sys.version_info >= (3, 11), sys.version.split()[0])
|
|
46
|
+
storage = sqlite_path(config)
|
|
47
|
+
if storage is None:
|
|
48
|
+
add("storage", True, config.storage_url)
|
|
49
|
+
else:
|
|
50
|
+
try:
|
|
51
|
+
storage.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
add("storage", True, str(storage))
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
add("storage", False, str(exc))
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with socket.socket() as probe:
|
|
58
|
+
probe.settimeout(0.3)
|
|
59
|
+
available = probe.connect_ex((config.host, config.port)) != 0
|
|
60
|
+
if available:
|
|
61
|
+
add("port", True, f"{config.host}:{config.port} available")
|
|
62
|
+
else:
|
|
63
|
+
status = fetch_status(f"http://{config.host}:{config.port}")
|
|
64
|
+
add(
|
|
65
|
+
"port",
|
|
66
|
+
status["running"],
|
|
67
|
+
f"{config.host}:{config.port} "
|
|
68
|
+
+ ("Agent Loop Guard is running" if status["running"] else "used by another process"),
|
|
69
|
+
)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
add("port", False, str(exc))
|
|
72
|
+
|
|
73
|
+
add("docker", shutil.which("docker") is not None, shutil.which("docker") or "not installed")
|
|
74
|
+
add("wsl", shutil.which("wsl") is not None, shutil.which("wsl") or "not installed")
|
|
75
|
+
return {"ok": all(item["ok"] for item in checks if item["name"] != "docker"), "checks": checks}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fetch_status(base_url: str) -> dict[str, Any]:
|
|
79
|
+
try:
|
|
80
|
+
response = httpx.get(base_url.rstrip("/") + "/api/health", timeout=2)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return {"running": True, "url": base_url, **response.json()}
|
|
83
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
84
|
+
return {"running": False, "url": base_url, "error": str(exc)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_backup(config: AppConfig, destination: Path, config_path: Path | None = None) -> Path:
|
|
88
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
manifest = {
|
|
90
|
+
"schema_version": "backup.v1",
|
|
91
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
92
|
+
"storage_url": config.storage_url,
|
|
93
|
+
}
|
|
94
|
+
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
95
|
+
archive.writestr("manifest.json", json.dumps(manifest, indent=2))
|
|
96
|
+
storage = sqlite_path(config)
|
|
97
|
+
if storage and storage.exists():
|
|
98
|
+
archive.write(storage, "data/agent_loop_guard.db")
|
|
99
|
+
if config_path and config_path.exists():
|
|
100
|
+
archive.write(config_path, "agent-loop-guard.yml")
|
|
101
|
+
return destination
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def restore_backup(config: AppConfig, source: Path, *, force: bool = False) -> Path:
|
|
105
|
+
storage = sqlite_path(config)
|
|
106
|
+
if storage is None:
|
|
107
|
+
raise ValueError("Restore currently supports file-backed SQLite storage only.")
|
|
108
|
+
if storage.exists() and not force:
|
|
109
|
+
raise FileExistsError(f"{storage} exists; pass --force to replace it.")
|
|
110
|
+
with zipfile.ZipFile(source) as archive:
|
|
111
|
+
if "data/agent_loop_guard.db" not in archive.namelist():
|
|
112
|
+
raise ValueError("Backup does not contain a SQLite database.")
|
|
113
|
+
storage.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with archive.open("data/agent_loop_guard.db") as src, storage.open("wb") as dst:
|
|
115
|
+
shutil.copyfileobj(src, dst)
|
|
116
|
+
return storage
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cleanup_old_data(db: Session, retention_days: int) -> dict[str, int]:
|
|
120
|
+
cutoff = datetime.now(UTC) - timedelta(days=max(1, retention_days))
|
|
121
|
+
old_trace_ids = select(TraceRun.id).where(TraceRun.updated_at < cutoff)
|
|
122
|
+
db.execute(delete(TraceArtifact).where(TraceArtifact.trace_id.in_(old_trace_ids)))
|
|
123
|
+
db.execute(delete(TraceEvent).where(TraceEvent.trace_id.in_(old_trace_ids)))
|
|
124
|
+
db.execute(delete(TraceSpan).where(TraceSpan.trace_id.in_(old_trace_ids)))
|
|
125
|
+
trace_result = db.execute(delete(TraceRun).where(TraceRun.updated_at < cutoff))
|
|
126
|
+
|
|
127
|
+
old_session_ids = select(GuardSession.id).where(GuardSession.updated_at < cutoff)
|
|
128
|
+
old_request_ids = select(RequestRecord.id).where(RequestRecord.session_id.in_(old_session_ids))
|
|
129
|
+
db.execute(update(TraceRun).where(TraceRun.source_session_id.in_(old_session_ids)).values(source_session_id=None))
|
|
130
|
+
db.execute(delete(ToolCall).where(ToolCall.request_id.in_(old_request_ids)))
|
|
131
|
+
db.execute(delete(Event).where(Event.session_id.in_(old_session_ids)))
|
|
132
|
+
db.execute(delete(RequestRecord).where(RequestRecord.session_id.in_(old_session_ids)))
|
|
133
|
+
session_result = db.execute(delete(GuardSession).where(GuardSession.updated_at < cutoff))
|
|
134
|
+
db.commit()
|
|
135
|
+
return {
|
|
136
|
+
"traces": int(trace_result.rowcount or 0),
|
|
137
|
+
"sessions": int(session_result.rowcount or 0),
|
|
138
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from alembic import command
|
|
6
|
+
from alembic.config import Config
|
|
7
|
+
from sqlalchemy import inspect
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def alembic_config(storage_url: str) -> Config:
|
|
11
|
+
script_location = Path(__file__).with_name("alembic")
|
|
12
|
+
config = Config()
|
|
13
|
+
config.set_main_option("script_location", str(script_location))
|
|
14
|
+
config.set_main_option("sqlalchemy.url", storage_url.replace("%", "%%"))
|
|
15
|
+
return config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def migrate_database(engine, storage_url: str) -> None:
|
|
19
|
+
config = alembic_config(storage_url)
|
|
20
|
+
tables = set(inspect(engine).get_table_names())
|
|
21
|
+
if tables and "alembic_version" not in tables:
|
|
22
|
+
# v0.1 databases were created from the same SQLAlchemy metadata.
|
|
23
|
+
command.stamp(config, "0001_initial")
|
|
24
|
+
command.upgrade(config, "head")
|
app/platform/setup.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from app.core.config import SAMPLE_CONFIG
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_profiles(base_url: str, gateway_key: str) -> dict[str, str]:
|
|
13
|
+
openai_url = base_url.rstrip("/") + "/v1"
|
|
14
|
+
return {
|
|
15
|
+
"codex.toml": (
|
|
16
|
+
'model = "demo-model"\n'
|
|
17
|
+
'model_provider = "agent_loop_guard"\n\n'
|
|
18
|
+
"[model_providers.agent_loop_guard]\n"
|
|
19
|
+
'name = "Agent Loop Guard"\n'
|
|
20
|
+
f'base_url = "{openai_url}"\n'
|
|
21
|
+
'env_key = "ALG_GATEWAY_KEY"\n'
|
|
22
|
+
),
|
|
23
|
+
"claude.env": (
|
|
24
|
+
f"ANTHROPIC_BASE_URL={base_url.rstrip('/')}\n"
|
|
25
|
+
f"ANTHROPIC_AUTH_TOKEN={gateway_key}\n"
|
|
26
|
+
),
|
|
27
|
+
"cline.txt": (
|
|
28
|
+
"API Provider: OpenAI Compatible\n"
|
|
29
|
+
f"Base URL: {openai_url}\n"
|
|
30
|
+
f"API Key: {gateway_key}\n"
|
|
31
|
+
"Model ID: demo-model\n"
|
|
32
|
+
),
|
|
33
|
+
"opencode.json": json.dumps(
|
|
34
|
+
{
|
|
35
|
+
"provider": {
|
|
36
|
+
"alg": {
|
|
37
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
38
|
+
"name": "Agent Loop Guard",
|
|
39
|
+
"options": {"baseURL": openai_url, "apiKey": "{env:ALG_GATEWAY_KEY}"},
|
|
40
|
+
"models": {"demo-model": {"name": "Guarded model"}},
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"model": "alg/demo-model",
|
|
44
|
+
},
|
|
45
|
+
indent=2,
|
|
46
|
+
)
|
|
47
|
+
+ "\n",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_workspace(
|
|
52
|
+
root: Path,
|
|
53
|
+
*,
|
|
54
|
+
host: str = "127.0.0.1",
|
|
55
|
+
port: int = 8787,
|
|
56
|
+
gateway_key: str | None = None,
|
|
57
|
+
force: bool = False,
|
|
58
|
+
) -> dict[str, object]:
|
|
59
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
config_path = root / "agent-loop-guard.yml"
|
|
61
|
+
profiles_dir = root / ".agent-loop-guard" / "profiles"
|
|
62
|
+
profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
if config_path.exists() and not force:
|
|
65
|
+
config_written = False
|
|
66
|
+
existing = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
67
|
+
effective_gateway_key = str(existing.get("gateway_key") or gateway_key or "alg_demo_key")
|
|
68
|
+
else:
|
|
69
|
+
effective_gateway_key = gateway_key or f"alg_{secrets.token_urlsafe(24)}"
|
|
70
|
+
config_text = SAMPLE_CONFIG.replace(
|
|
71
|
+
"gateway_key: alg_demo_key", f"gateway_key: {effective_gateway_key}"
|
|
72
|
+
)
|
|
73
|
+
config_text = config_text.replace("host: 127.0.0.1", f"host: {host}", 1)
|
|
74
|
+
config_text = config_text.replace("port: 8787", f"port: {port}", 1)
|
|
75
|
+
config_path.write_text(config_text, encoding="utf-8")
|
|
76
|
+
config_written = True
|
|
77
|
+
|
|
78
|
+
base_url = f"http://{host}:{port}"
|
|
79
|
+
written_profiles = []
|
|
80
|
+
for name, content in render_profiles(base_url, effective_gateway_key).items():
|
|
81
|
+
path = profiles_dir / name
|
|
82
|
+
if force or not path.exists():
|
|
83
|
+
path.write_text(content, encoding="utf-8")
|
|
84
|
+
written_profiles.append(str(path))
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"config": str(config_path),
|
|
88
|
+
"config_written": config_written,
|
|
89
|
+
"profiles": written_profiles,
|
|
90
|
+
"base_url": base_url,
|
|
91
|
+
"gateway_key": effective_gateway_key if config_written else None,
|
|
92
|
+
}
|
app/providers/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class ProviderResult:
|
|
10
|
+
status_code: int
|
|
11
|
+
headers: dict[str, str]
|
|
12
|
+
content: bytes
|
|
13
|
+
json_body: Any | None = None
|
|
14
|
+
media_type: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ProviderStream:
|
|
19
|
+
status_code: int
|
|
20
|
+
headers: dict[str, str]
|
|
21
|
+
chunks: AsyncIterator[bytes]
|
|
22
|
+
media_type: str = "text/event-stream"
|
|
23
|
+
|