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/providers/mock.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from app.core.token_meter import estimate_tokens
|
|
10
|
+
from app.providers.base import ProviderResult, ProviderStream
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _json_bytes(value: dict[str, Any]) -> bytes:
|
|
14
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _text_from_body(body: dict[str, Any]) -> str:
|
|
18
|
+
for key in ("input", "prompt"):
|
|
19
|
+
value = body.get(key)
|
|
20
|
+
if isinstance(value, str) and value.strip():
|
|
21
|
+
return value.strip()
|
|
22
|
+
messages = body.get("messages")
|
|
23
|
+
if isinstance(messages, list):
|
|
24
|
+
for item in reversed(messages):
|
|
25
|
+
if isinstance(item, dict):
|
|
26
|
+
content = item.get("content")
|
|
27
|
+
if isinstance(content, str) and content.strip():
|
|
28
|
+
return content.strip()
|
|
29
|
+
if isinstance(content, list):
|
|
30
|
+
parts = [
|
|
31
|
+
str(part.get("text"))
|
|
32
|
+
for part in content
|
|
33
|
+
if isinstance(part, dict) and part.get("text")
|
|
34
|
+
]
|
|
35
|
+
if parts:
|
|
36
|
+
return " ".join(parts)
|
|
37
|
+
return "mock request"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _usage(protocol: str, body: dict[str, Any], output: str) -> dict[str, int]:
|
|
41
|
+
input_tokens = estimate_tokens(body)
|
|
42
|
+
output_tokens = estimate_tokens(output)
|
|
43
|
+
if protocol == "anthropic":
|
|
44
|
+
return {"input_tokens": input_tokens, "output_tokens": output_tokens}
|
|
45
|
+
return {
|
|
46
|
+
"prompt_tokens": input_tokens,
|
|
47
|
+
"completion_tokens": output_tokens,
|
|
48
|
+
"total_tokens": input_tokens + output_tokens,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MockProvider:
|
|
53
|
+
"""Deterministic local provider used for demos and tests."""
|
|
54
|
+
|
|
55
|
+
async def models(self) -> ProviderResult:
|
|
56
|
+
payload = {
|
|
57
|
+
"object": "list",
|
|
58
|
+
"data": [
|
|
59
|
+
{"id": "demo-model", "object": "model", "created": 0, "owned_by": "agent-loop-guard"},
|
|
60
|
+
{"id": "mock-loop-model", "object": "model", "created": 0, "owned_by": "agent-loop-guard"},
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
|
|
64
|
+
|
|
65
|
+
async def count_tokens(self, body: dict[str, Any]) -> ProviderResult:
|
|
66
|
+
payload = {"input_tokens": estimate_tokens(body)}
|
|
67
|
+
return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
|
|
68
|
+
|
|
69
|
+
async def request(self, protocol: str, endpoint: str, body: dict[str, Any]) -> ProviderResult:
|
|
70
|
+
if endpoint.endswith("/models"):
|
|
71
|
+
return await self.models()
|
|
72
|
+
if endpoint.endswith("/messages/count_tokens"):
|
|
73
|
+
return await self.count_tokens(body)
|
|
74
|
+
|
|
75
|
+
status = int(body.get("mock_status") or 200)
|
|
76
|
+
if body.get("mock_error") or status >= 400:
|
|
77
|
+
payload = {
|
|
78
|
+
"error": {
|
|
79
|
+
"type": "mock_error",
|
|
80
|
+
"code": "mock_error",
|
|
81
|
+
"message": str(body.get("mock_error") or "mock upstream error"),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ProviderResult(status, {"content-type": "application/json"}, _json_bytes(payload), payload)
|
|
85
|
+
|
|
86
|
+
prompt = _text_from_body(body)
|
|
87
|
+
output = f"Mock response for: {prompt[:120]}"
|
|
88
|
+
model = str(body.get("model") or "demo-model")
|
|
89
|
+
if protocol == "anthropic":
|
|
90
|
+
payload = {
|
|
91
|
+
"id": f"msg_{uuid.uuid4().hex[:16]}",
|
|
92
|
+
"type": "message",
|
|
93
|
+
"role": "assistant",
|
|
94
|
+
"model": model,
|
|
95
|
+
"content": [{"type": "text", "text": output}],
|
|
96
|
+
"stop_reason": "end_turn",
|
|
97
|
+
"usage": _usage(protocol, body, output),
|
|
98
|
+
}
|
|
99
|
+
elif endpoint.endswith("/responses"):
|
|
100
|
+
payload = {
|
|
101
|
+
"id": f"resp_{uuid.uuid4().hex[:16]}",
|
|
102
|
+
"object": "response",
|
|
103
|
+
"model": model,
|
|
104
|
+
"output": [
|
|
105
|
+
{
|
|
106
|
+
"type": "message",
|
|
107
|
+
"role": "assistant",
|
|
108
|
+
"content": [{"type": "output_text", "text": output}],
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"usage": _usage(protocol, body, output),
|
|
112
|
+
}
|
|
113
|
+
else:
|
|
114
|
+
payload = {
|
|
115
|
+
"id": f"chatcmpl_{uuid.uuid4().hex[:16]}",
|
|
116
|
+
"object": "chat.completion",
|
|
117
|
+
"model": model,
|
|
118
|
+
"choices": [
|
|
119
|
+
{
|
|
120
|
+
"index": 0,
|
|
121
|
+
"finish_reason": "stop",
|
|
122
|
+
"message": {"role": "assistant", "content": output},
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
"usage": _usage(protocol, body, output),
|
|
126
|
+
}
|
|
127
|
+
return ProviderResult(200, {"content-type": "application/json"}, _json_bytes(payload), payload)
|
|
128
|
+
|
|
129
|
+
async def stream(self, protocol: str, endpoint: str, body: dict[str, Any]) -> ProviderStream:
|
|
130
|
+
prompt = _text_from_body(body)
|
|
131
|
+
model = str(body.get("model") or "demo-model")
|
|
132
|
+
chunks = [
|
|
133
|
+
f"Mock streaming response for: {prompt[:80]}",
|
|
134
|
+
" -- guarded by Agent Loop Guard.",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
async def iterator() -> AsyncIterator[bytes]:
|
|
138
|
+
if protocol == "anthropic":
|
|
139
|
+
yield b"event: message_start\n"
|
|
140
|
+
start = {"type": "message_start", "message": {"id": "msg_mock", "model": model}}
|
|
141
|
+
yield b"data: " + _json_bytes(start) + b"\n\n"
|
|
142
|
+
for chunk in chunks:
|
|
143
|
+
await asyncio.sleep(0)
|
|
144
|
+
payload = {"type": "content_block_delta", "delta": {"type": "text_delta", "text": chunk}}
|
|
145
|
+
yield b"event: content_block_delta\n"
|
|
146
|
+
yield b"data: " + _json_bytes(payload) + b"\n\n"
|
|
147
|
+
usage = _usage(protocol, body, "".join(chunks))
|
|
148
|
+
yield b"event: message_delta\n"
|
|
149
|
+
yield b"data: " + _json_bytes({"type": "message_delta", "usage": usage}) + b"\n\n"
|
|
150
|
+
yield b"event: message_stop\n"
|
|
151
|
+
yield b"data: {\"type\":\"message_stop\"}\n\n"
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
for chunk in chunks:
|
|
155
|
+
await asyncio.sleep(0)
|
|
156
|
+
if endpoint.endswith("/responses"):
|
|
157
|
+
payload = {"type": "response.output_text.delta", "delta": chunk}
|
|
158
|
+
else:
|
|
159
|
+
payload = {
|
|
160
|
+
"id": "chatcmpl_mock",
|
|
161
|
+
"object": "chat.completion.chunk",
|
|
162
|
+
"model": model,
|
|
163
|
+
"choices": [{"index": 0, "delta": {"content": chunk}, "finish_reason": None}],
|
|
164
|
+
}
|
|
165
|
+
yield b"data: " + _json_bytes(payload) + b"\n\n"
|
|
166
|
+
yield b"data: [DONE]\n\n"
|
|
167
|
+
|
|
168
|
+
return ProviderStream(200, {"content-type": "text/event-stream"}, iterator())
|
|
169
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from urllib.parse import urljoin, urlparse
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from app.core.security import filtered_upstream_headers
|
|
9
|
+
from app.providers.base import ProviderResult, ProviderStream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_base_url(base_url: str) -> str:
|
|
13
|
+
parsed = urlparse(base_url)
|
|
14
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
15
|
+
raise ValueError("Upstream base URL must be an absolute http(s) URL.")
|
|
16
|
+
return base_url.rstrip("/") + "/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _target(base_url: str, endpoint: str) -> str:
|
|
20
|
+
return urljoin(validate_base_url(base_url), endpoint.lstrip("/"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _response_headers(headers: httpx.Headers) -> dict[str, str]:
|
|
24
|
+
hop_by_hop = {
|
|
25
|
+
"connection",
|
|
26
|
+
"content-length",
|
|
27
|
+
"keep-alive",
|
|
28
|
+
"proxy-authenticate",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
"te",
|
|
31
|
+
"trailer",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"upgrade",
|
|
34
|
+
}
|
|
35
|
+
return {key: value for key, value in headers.items() if key.lower() not in hop_by_hop}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UpstreamProvider:
|
|
39
|
+
def __init__(self, *, base_url: str, api_key: str | None, protocol: str):
|
|
40
|
+
self.base_url = validate_base_url(base_url)
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.protocol = protocol
|
|
43
|
+
|
|
44
|
+
def _headers(self, incoming: dict[str, str]) -> dict[str, str]:
|
|
45
|
+
headers = filtered_upstream_headers(incoming)
|
|
46
|
+
if self.api_key:
|
|
47
|
+
if self.protocol == "anthropic":
|
|
48
|
+
headers["x-api-key"] = self.api_key
|
|
49
|
+
headers.setdefault("anthropic-version", "2023-06-01")
|
|
50
|
+
else:
|
|
51
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
52
|
+
return headers
|
|
53
|
+
|
|
54
|
+
async def request(self, endpoint: str, raw_body: bytes, incoming_headers: dict[str, str]) -> ProviderResult:
|
|
55
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
56
|
+
response = await client.request(
|
|
57
|
+
"POST" if raw_body else "GET",
|
|
58
|
+
_target(self.base_url, endpoint),
|
|
59
|
+
content=raw_body if raw_body else None,
|
|
60
|
+
headers=self._headers(incoming_headers),
|
|
61
|
+
)
|
|
62
|
+
json_body = None
|
|
63
|
+
try:
|
|
64
|
+
json_body = response.json()
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
return ProviderResult(
|
|
68
|
+
response.status_code,
|
|
69
|
+
_response_headers(response.headers),
|
|
70
|
+
response.content,
|
|
71
|
+
json_body,
|
|
72
|
+
response.headers.get("content-type"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def stream(
|
|
76
|
+
self, endpoint: str, raw_body: bytes, incoming_headers: dict[str, str]
|
|
77
|
+
) -> ProviderStream:
|
|
78
|
+
client = httpx.AsyncClient(timeout=None)
|
|
79
|
+
request = client.build_request(
|
|
80
|
+
"POST",
|
|
81
|
+
_target(self.base_url, endpoint),
|
|
82
|
+
content=raw_body,
|
|
83
|
+
headers=self._headers(incoming_headers),
|
|
84
|
+
)
|
|
85
|
+
response = await client.send(request, stream=True)
|
|
86
|
+
|
|
87
|
+
async def iterator() -> AsyncIterator[bytes]:
|
|
88
|
+
try:
|
|
89
|
+
async for chunk in response.aiter_bytes():
|
|
90
|
+
yield chunk
|
|
91
|
+
finally:
|
|
92
|
+
await response.aclose()
|
|
93
|
+
await client.aclose()
|
|
94
|
+
|
|
95
|
+
return ProviderStream(
|
|
96
|
+
response.status_code,
|
|
97
|
+
_response_headers(response.headers),
|
|
98
|
+
iterator(),
|
|
99
|
+
response.headers.get("content-type") or "text/event-stream",
|
|
100
|
+
)
|
|
101
|
+
|
app/replay/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Session Replay SDK and export helpers."""
|
app/replay/costs.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_pricing(path: str | Path | None = None) -> dict[str, dict[str, int]]:
|
|
11
|
+
selected = Path(path or os.getenv("ALG_MODEL_PRICING", "model-pricing.yml"))
|
|
12
|
+
if not selected.exists():
|
|
13
|
+
return {"demo-model": {"input_micros_per_million": 0, "output_micros_per_million": 0}}
|
|
14
|
+
with selected.open(encoding="utf-8") as handle:
|
|
15
|
+
data: dict[str, Any] = yaml.safe_load(handle) or {}
|
|
16
|
+
return {
|
|
17
|
+
str(model): {
|
|
18
|
+
"input_micros_per_million": int(row.get("input_micros_per_million", 0)),
|
|
19
|
+
"output_micros_per_million": int(row.get("output_micros_per_million", 0)),
|
|
20
|
+
}
|
|
21
|
+
for model, row in (data.get("models") or {}).items()
|
|
22
|
+
if isinstance(row, dict)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def estimate_cost_micros(
|
|
27
|
+
model: str | None, input_tokens: int, output_tokens: int, pricing: dict | None = None
|
|
28
|
+
) -> tuple[int, bool]:
|
|
29
|
+
catalog = pricing or load_pricing()
|
|
30
|
+
row = catalog.get(model or "")
|
|
31
|
+
if not row:
|
|
32
|
+
return 0, True
|
|
33
|
+
total = (
|
|
34
|
+
input_tokens * int(row.get("input_micros_per_million", 0))
|
|
35
|
+
+ output_tokens * int(row.get("output_micros_per_million", 0))
|
|
36
|
+
) // 1_000_000
|
|
37
|
+
return total, True
|
app/replay/formats.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def trace_to_jsonl(bundle: dict[str, Any]) -> str:
|
|
9
|
+
rows = [{"record_type": "run", **bundle["run"]}]
|
|
10
|
+
rows.extend({"record_type": "span", **item} for item in bundle.get("spans", []))
|
|
11
|
+
rows.extend({"record_type": "event", **item} for item in bundle.get("events", []))
|
|
12
|
+
rows.extend({"record_type": "artifact", **item} for item in bundle.get("artifacts", []))
|
|
13
|
+
return "".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def trace_to_otel(bundle: dict[str, Any]) -> dict[str, Any]:
|
|
17
|
+
run = bundle["run"]
|
|
18
|
+
trace_id = hashlib.sha256(str(run["id"]).encode("utf-8")).hexdigest()[:32]
|
|
19
|
+
|
|
20
|
+
def otel_span_id(value: str) -> str:
|
|
21
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
|
22
|
+
|
|
23
|
+
spans = []
|
|
24
|
+
events_by_span: dict[str, list[dict[str, Any]]] = {}
|
|
25
|
+
for event in bundle.get("events", []):
|
|
26
|
+
if event.get("span_id"):
|
|
27
|
+
events_by_span.setdefault(event["span_id"], []).append(event)
|
|
28
|
+
for span in bundle.get("spans", []):
|
|
29
|
+
spans.append(
|
|
30
|
+
{
|
|
31
|
+
"traceId": trace_id,
|
|
32
|
+
"spanId": otel_span_id(span["id"]),
|
|
33
|
+
"parentSpanId": (
|
|
34
|
+
otel_span_id(span["parent_span_id"])
|
|
35
|
+
if span.get("parent_span_id")
|
|
36
|
+
else ""
|
|
37
|
+
),
|
|
38
|
+
"name": span["name"],
|
|
39
|
+
"startTimeUnixNano": str(span["start_ns"]),
|
|
40
|
+
"endTimeUnixNano": str(span.get("end_ns") or span["start_ns"]),
|
|
41
|
+
"status": {"code": "STATUS_CODE_ERROR" if span["status"] in {"error", "blocked"} else "STATUS_CODE_OK"},
|
|
42
|
+
"attributes": [
|
|
43
|
+
{"key": str(key), "value": {"stringValue": str(value)}}
|
|
44
|
+
for key, value in span.get("attributes", {}).items()
|
|
45
|
+
],
|
|
46
|
+
"events": [
|
|
47
|
+
{
|
|
48
|
+
"timeUnixNano": str(event["timestamp_ns"]),
|
|
49
|
+
"name": event["name"],
|
|
50
|
+
"attributes": [
|
|
51
|
+
{"key": str(key), "value": {"stringValue": str(value)}}
|
|
52
|
+
for key, value in event.get("attributes", {}).items()
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
for event in events_by_span.get(span["id"], [])
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
"resourceSpans": [
|
|
61
|
+
{
|
|
62
|
+
"resource": {
|
|
63
|
+
"attributes": [
|
|
64
|
+
{"key": "service.name", "value": {"stringValue": "agent-loop-guard"}},
|
|
65
|
+
{"key": "project.id", "value": {"stringValue": run["project_id"]}},
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"scopeSpans": [{"scope": {"name": "agent-loop-guard.replay"}, "spans": spans}],
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def jsonl_to_trace(text: str) -> dict[str, Any]:
|
|
75
|
+
bundle: dict[str, Any] = {"spans": [], "events": [], "artifacts": []}
|
|
76
|
+
for line in text.splitlines():
|
|
77
|
+
if not line.strip():
|
|
78
|
+
continue
|
|
79
|
+
row = json.loads(line)
|
|
80
|
+
record_type = row.pop("record_type", None)
|
|
81
|
+
if record_type == "run":
|
|
82
|
+
bundle["run"] = row
|
|
83
|
+
elif record_type in {"span", "event", "artifact"}:
|
|
84
|
+
bundle[f"{record_type}s"].append(row)
|
|
85
|
+
if "run" not in bundle:
|
|
86
|
+
raise ValueError("JSONL bundle does not contain a run record.")
|
|
87
|
+
return bundle
|
app/replay/sdk.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from app.core.redaction import redact_value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReplayClient:
|
|
15
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8787", timeout: float = 10):
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
|
|
19
|
+
def trace(self, **attributes: Any) -> TraceContext:
|
|
20
|
+
return TraceContext(self, attributes)
|
|
21
|
+
|
|
22
|
+
def ingest(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
23
|
+
response = httpx.post(
|
|
24
|
+
f"{self.base_url}/api/v1/traces", json=redact_value(payload), timeout=self.timeout
|
|
25
|
+
)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response.json()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SpanContext(AbstractContextManager["SpanContext"]):
|
|
32
|
+
trace: TraceContext
|
|
33
|
+
name: str
|
|
34
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
parent_span_id: str | None = None
|
|
36
|
+
span_id: str = field(default_factory=lambda: f"spn_{uuid.uuid4().hex[:24]}")
|
|
37
|
+
start_ns: int = field(default_factory=time.time_ns)
|
|
38
|
+
end_ns: int | None = None
|
|
39
|
+
status: str = "ok"
|
|
40
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def event(self, name: str, severity: str = "info", **attributes: Any) -> None:
|
|
43
|
+
self.events.append(
|
|
44
|
+
{
|
|
45
|
+
"name": name,
|
|
46
|
+
"severity": severity,
|
|
47
|
+
"timestamp_ns": time.time_ns(),
|
|
48
|
+
"attributes": attributes,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
53
|
+
self.end_ns = time.time_ns()
|
|
54
|
+
if exc is not None:
|
|
55
|
+
self.status = "error"
|
|
56
|
+
self.event("exception", "error", type=type(exc).__name__, message=str(exc))
|
|
57
|
+
self.trace.spans.append(
|
|
58
|
+
{
|
|
59
|
+
"span_id": self.span_id,
|
|
60
|
+
"parent_span_id": self.parent_span_id,
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"status": self.status,
|
|
63
|
+
"start_ns": self.start_ns,
|
|
64
|
+
"end_ns": self.end_ns,
|
|
65
|
+
"attributes": self.attributes,
|
|
66
|
+
"events": self.events,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class TraceContext(AbstractContextManager["TraceContext"]):
|
|
74
|
+
client: ReplayClient
|
|
75
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
trace_id: str = field(default_factory=lambda: f"trc_{uuid.uuid4().hex[:24]}")
|
|
77
|
+
start_ns: int = field(default_factory=time.time_ns)
|
|
78
|
+
spans: list[dict[str, Any]] = field(default_factory=list)
|
|
79
|
+
result: dict[str, Any] | None = None
|
|
80
|
+
|
|
81
|
+
def span(
|
|
82
|
+
self,
|
|
83
|
+
name: str,
|
|
84
|
+
*,
|
|
85
|
+
parent_span_id: str | None = None,
|
|
86
|
+
**attributes: Any,
|
|
87
|
+
) -> SpanContext:
|
|
88
|
+
return SpanContext(self, name, attributes, parent_span_id)
|
|
89
|
+
|
|
90
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
91
|
+
status = "error" if exc is not None else "ok"
|
|
92
|
+
self.result = self.client.ingest(
|
|
93
|
+
{
|
|
94
|
+
"trace_id": self.trace_id,
|
|
95
|
+
"status": status,
|
|
96
|
+
"start_ns": self.start_ns,
|
|
97
|
+
"end_ns": time.time_ns(),
|
|
98
|
+
"attributes": self.attributes,
|
|
99
|
+
"spans": self.spans,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return False
|
app/sandbox/__init__.py
ADDED
app/sandbox/policy.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
DENIED_COMMAND_PATTERNS = (
|
|
6
|
+
r"\b(?:docker|podman)\b",
|
|
7
|
+
r"\bmount\b",
|
|
8
|
+
r"\b(?:shutdown|reboot|poweroff)\b",
|
|
9
|
+
r"\bnsenter\b",
|
|
10
|
+
r"/proc/(?:1|sysrq-trigger)",
|
|
11
|
+
r":\s*\(\s*\)\s*\{.*:\s*\|\s*:",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_command(command: list[str]) -> None:
|
|
16
|
+
if not command:
|
|
17
|
+
raise ValueError("A command is required after --")
|
|
18
|
+
rendered = " ".join(command)
|
|
19
|
+
for pattern in DENIED_COMMAND_PATTERNS:
|
|
20
|
+
if re.search(pattern, rendered, re.IGNORECASE):
|
|
21
|
+
raise ValueError(f"Command denied by sandbox policy: {pattern}")
|
|
22
|
+
|