anchor-runtime 1.4.2__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.
- anchor/__init__.py +42 -0
- anchor/api/__init__.py +7 -0
- anchor/api/app.py +310 -0
- anchor/api/authoring/__init__.py +5 -0
- anchor/api/authoring/checks/__init__.py +1 -0
- anchor/api/authoring/checks/determinism.py +28 -0
- anchor/api/authoring/checks/module_state.py +88 -0
- anchor/api/authoring/checks/recursion.py +54 -0
- anchor/api/authoring/checks/return_shape.py +78 -0
- anchor/api/authoring/checks/safety.py +50 -0
- anchor/api/authoring/checks/tool_names.py +49 -0
- anchor/api/authoring/messages.py +65 -0
- anchor/api/authoring/models.py +69 -0
- anchor/api/authoring/register.py +100 -0
- anchor/api/authoring/validator.py +56 -0
- anchor/api/errors.py +74 -0
- anchor/api/middleware.py +167 -0
- anchor/api/routers/__init__.py +1 -0
- anchor/api/routers/authoring.py +108 -0
- anchor/api/routers/chaos.py +257 -0
- anchor/api/routers/config.py +152 -0
- anchor/api/routers/health.py +155 -0
- anchor/api/routers/observability.py +312 -0
- anchor/api/routers/registry.py +93 -0
- anchor/api/routers/runs.py +622 -0
- anchor/api/routers/workers.py +156 -0
- anchor/api/serializers/__init__.py +1 -0
- anchor/api/serializers/chaos.py +132 -0
- anchor/api/serializers/rollup.py +275 -0
- anchor/api/serializers/runs.py +114 -0
- anchor/api/serializers/timeline.py +308 -0
- anchor/api/serializers/workers.py +70 -0
- anchor/api/ws/__init__.py +1 -0
- anchor/api/ws/backpressure.py +61 -0
- anchor/api/ws/fleet.py +85 -0
- anchor/api/ws/orphan_watcher.py +70 -0
- anchor/api/ws/runs.py +99 -0
- anchor/api/ws/subscriber.py +139 -0
- anchor/chaos/__init__.py +7 -0
- anchor/chaos/chaos_worker.py +98 -0
- anchor/chaos/harness.py +323 -0
- anchor/chaos/injections/__init__.py +5 -0
- anchor/chaos/injections/kill.py +55 -0
- anchor/chaos/injections/latency.py +44 -0
- anchor/chaos/injections/stall.py +43 -0
- anchor/chaos/injections/tool_failure.py +42 -0
- anchor/chaos/injections/uncertainty.py +131 -0
- anchor/chaos/invariants.py +308 -0
- anchor/chaos/recorder.py +41 -0
- anchor/chaos/report.py +265 -0
- anchor/chaos/workload.py +52 -0
- anchor/cli.py +186 -0
- anchor/core/__init__.py +7 -0
- anchor/core/config/__init__.py +7 -0
- anchor/core/config/assertion.py +61 -0
- anchor/core/config/live.py +158 -0
- anchor/core/config/loader.py +72 -0
- anchor/core/config/profiles.py +77 -0
- anchor/core/config/settings.py +75 -0
- anchor/core/db/__init__.py +6 -0
- anchor/core/db/errors.py +163 -0
- anchor/core/db/pool.py +64 -0
- anchor/core/db/schema_gate.py +75 -0
- anchor/core/determinism/__init__.py +7 -0
- anchor/core/determinism/actions.py +39 -0
- anchor/core/determinism/ast_check.py +83 -0
- anchor/core/determinism/buffer.py +72 -0
- anchor/core/determinism/context.py +352 -0
- anchor/core/events/__init__.py +7 -0
- anchor/core/events/append.py +144 -0
- anchor/core/events/models.py +21 -0
- anchor/core/events/payloads.py +174 -0
- anchor/core/events/publish.py +136 -0
- anchor/core/events/types.py +30 -0
- anchor/core/journal/__init__.py +7 -0
- anchor/core/journal/canonical.py +116 -0
- anchor/core/journal/keys.py +50 -0
- anchor/core/journal/lookup.py +98 -0
- anchor/core/journal/policies.py +324 -0
- anchor/core/journal/reconcile.py +44 -0
- anchor/core/journal/tool_protocol.py +38 -0
- anchor/core/journal/two_phase.py +358 -0
- anchor/core/leases/__init__.py +7 -0
- anchor/core/leases/claim.py +186 -0
- anchor/core/leases/fencing.py +124 -0
- anchor/core/leases/renew.py +179 -0
- anchor/core/logging.py +40 -0
- anchor/core/replay/__init__.py +6 -0
- anchor/core/replay/context.py +137 -0
- anchor/core/replay/handlers.py +189 -0
- anchor/core/replay/load.py +39 -0
- anchor/core/replay/reconstruct.py +84 -0
- anchor/runner.py +229 -0
- anchor/runtime/__init__.py +1 -0
- anchor/runtime/agents/__init__.py +87 -0
- anchor/runtime/agents/adapter.py +56 -0
- anchor/runtime/agents/decorators.py +48 -0
- anchor/runtime/agents/demo_chaos_flaky.py +26 -0
- anchor/runtime/agents/demo_chaos_latency.py +21 -0
- anchor/runtime/agents/demo_long.py +56 -0
- anchor/runtime/agents/demo_minimal.py +22 -0
- anchor/runtime/agents/demo_short.py +58 -0
- anchor/runtime/agents/demo_unsafe.py +31 -0
- anchor/runtime/agents/registry.py +87 -0
- anchor/runtime/tools/__init__.py +1 -0
- anchor/runtime/tools/chaos.py +82 -0
- anchor/runtime/tools/decorators.py +86 -0
- anchor/runtime/tools/demo.py +306 -0
- anchor/runtime/tools/model.py +178 -0
- anchor/runtime/tools/registry.py +184 -0
- anchor/worker/__init__.py +6 -0
- anchor/worker/__main__.py +136 -0
- anchor/worker/admission/__init__.py +1 -0
- anchor/worker/admission/limiter.py +18 -0
- anchor/worker/loop.py +795 -0
- anchor/worker/registry/__init__.py +5 -0
- anchor/worker/registry/heartbeat.py +87 -0
- anchor/worker/registry/identity.py +72 -0
- anchor/worker/registry/kill.py +49 -0
- anchor/worker/registry/register.py +79 -0
- anchor/worker/renewer.py +101 -0
- anchor/worker/retry/__init__.py +1 -0
- anchor/worker/retry/backoff.py +28 -0
- anchor/worker/retry/policy.py +148 -0
- anchor_runtime-1.4.2.dist-info/METADATA +16 -0
- anchor_runtime-1.4.2.dist-info/RECORD +129 -0
- anchor_runtime-1.4.2.dist-info/WHEEL +4 -0
- anchor_runtime-1.4.2.dist-info/entry_points.txt +3 -0
- anchor_runtime-1.4.2.dist-info/licenses/LICENSE +17 -0
anchor/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
def _auto_load_dotenv() -> None:
|
|
4
|
+
"""Automatically loads .env from current directory or parents when anchor is imported."""
|
|
5
|
+
try:
|
|
6
|
+
curr = os.getcwd()
|
|
7
|
+
while curr:
|
|
8
|
+
dotenv_file = os.path.join(curr, ".env")
|
|
9
|
+
if os.path.exists(dotenv_file):
|
|
10
|
+
with open(dotenv_file, "r", encoding="utf-8") as f:
|
|
11
|
+
for line in f:
|
|
12
|
+
line = line.strip()
|
|
13
|
+
if line and not line.startswith("#") and "=" in line:
|
|
14
|
+
k, v = line.split("=", 1)
|
|
15
|
+
k, v = k.strip(), v.strip().strip("'\"")
|
|
16
|
+
if k and not os.environ.get(k):
|
|
17
|
+
os.environ[k] = v
|
|
18
|
+
break
|
|
19
|
+
parent = os.path.dirname(curr)
|
|
20
|
+
if parent == curr:
|
|
21
|
+
break
|
|
22
|
+
curr = parent
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
_auto_load_dotenv()
|
|
27
|
+
|
|
28
|
+
from anchor.core.determinism.actions import Done, ModelCall, ToolCall
|
|
29
|
+
from anchor.core.determinism.context import StepContext
|
|
30
|
+
from anchor.runner import run
|
|
31
|
+
from anchor.runtime.agents.decorators import agent
|
|
32
|
+
from anchor.runtime.tools.decorators import tool
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Done",
|
|
36
|
+
"ModelCall",
|
|
37
|
+
"StepContext",
|
|
38
|
+
"ToolCall",
|
|
39
|
+
"agent",
|
|
40
|
+
"run",
|
|
41
|
+
"tool",
|
|
42
|
+
]
|
anchor/api/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The thin HTTP/WebSocket surface. Nothing here enforces a safety property.
|
|
2
|
+
|
|
3
|
+
Every router is a translation layer over `core/` and `worker/registry`. The
|
|
4
|
+
one deliberate, documented exception is the operator-resolution write, which
|
|
5
|
+
uses `core.events.append` directly and is permitted only on a leaseless run
|
|
6
|
+
(research.md D-24).
|
|
7
|
+
"""
|
anchor/api/app.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""The FastAPI application factory.
|
|
2
|
+
|
|
3
|
+
Minimal in phase 0: health only. Phase 1 adds the runs routers and the
|
|
4
|
+
typed-error exception handlers (plan.md P1.7, T102); later phases add the
|
|
5
|
+
remaining routers named in `anchor/api/routers/__init__.py`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
|
|
17
|
+
import asyncpg
|
|
18
|
+
import redis.asyncio as redis_asyncio
|
|
19
|
+
from fastapi import FastAPI, Request
|
|
20
|
+
from fastapi.exceptions import RequestValidationError
|
|
21
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
24
|
+
|
|
25
|
+
from anchor.api.errors import error_body
|
|
26
|
+
from anchor.core.config.live import load_live_settings, poll_forever
|
|
27
|
+
from anchor.core.config.loader import BootstrapEnv
|
|
28
|
+
from anchor.core.db.errors import (
|
|
29
|
+
ConfigAssertionError,
|
|
30
|
+
ImmutableRecordError,
|
|
31
|
+
LeaseFencedError,
|
|
32
|
+
PayloadTooLargeError,
|
|
33
|
+
ResultOverwriteError,
|
|
34
|
+
)
|
|
35
|
+
from anchor.core.db.pool import create_pool
|
|
36
|
+
from anchor.core.db.schema_gate import SchemaVersionMismatchError, assert_schema_matches
|
|
37
|
+
from anchor.core.events.publish import configure_publisher
|
|
38
|
+
|
|
39
|
+
# Every typed database error this API can surface, mapped to its status
|
|
40
|
+
# code and its `contracts/openapi.yaml` `Error.error` machine code (T102).
|
|
41
|
+
# LeaseFencedError should never actually reach the API layer — fenced
|
|
42
|
+
# writes are a worker-internal concern (I3) — but the mapping exists so a
|
|
43
|
+
# bug that lets one leak through fails as a clear 409 rather than an
|
|
44
|
+
# unhandled 500.
|
|
45
|
+
_ERROR_STATUS_CODES: dict[type[Exception], tuple[int, str]] = {
|
|
46
|
+
LeaseFencedError: (409, "lease_fenced"),
|
|
47
|
+
# "config_assertion_failed" is one of the two machine codes
|
|
48
|
+
# contracts/openapi.yaml names explicitly as an example.
|
|
49
|
+
ConfigAssertionError: (422, "config_assertion_failed"),
|
|
50
|
+
ImmutableRecordError: (409, "immutable_record"),
|
|
51
|
+
ResultOverwriteError: (409, "result_overwrite"),
|
|
52
|
+
PayloadTooLargeError: (413, "payload_too_large"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
60
|
+
# database_url and redis_url are required fields with no Python-level
|
|
61
|
+
# default because they must always come from the environment
|
|
62
|
+
# (ANCHOR_DATABASE_URL / ANCHOR_REDIS_URL) — mypy cannot see
|
|
63
|
+
# pydantic-settings' env-sourcing, so it reads this as a missing
|
|
64
|
+
# argument. It is not: BaseSettings.__init__ populates required fields
|
|
65
|
+
# from the environment at runtime, and raises its own clear
|
|
66
|
+
# ValidationError if they are genuinely absent.
|
|
67
|
+
env = BootstrapEnv() # type: ignore[call-arg]
|
|
68
|
+
pool = await create_pool(env.database_url)
|
|
69
|
+
app.state.db_pool = pool
|
|
70
|
+
app.state.deployment_mode = "local" if env.authoring_execute else "demonstration"
|
|
71
|
+
app.state.config_profile = env.config_profile.value
|
|
72
|
+
app.state.code_version = env.code_version
|
|
73
|
+
|
|
74
|
+
# The API is a publisher (submission, resolve, cancel all append events
|
|
75
|
+
# of their own, P6.3/P6.7) and, via anchor.api.ws.subscriber, the one
|
|
76
|
+
# standing subscriber the console's WebSocket channels demultiplex from
|
|
77
|
+
# in process (D-50). Both roles share this one client.
|
|
78
|
+
redis_client = redis_asyncio.from_url(env.redis_url)
|
|
79
|
+
app.state.redis_client = redis_client
|
|
80
|
+
configure_publisher(redis_client)
|
|
81
|
+
|
|
82
|
+
from anchor.api.ws.subscriber import Hub
|
|
83
|
+
|
|
84
|
+
app.state.ws_hub = Hub()
|
|
85
|
+
|
|
86
|
+
background_tasks: list[asyncio.Task[None]] = []
|
|
87
|
+
app.state.chaos_tasks = set()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
async with pool.acquire(timeout=5.0) as conn:
|
|
91
|
+
await assert_schema_matches(conn)
|
|
92
|
+
except SchemaVersionMismatchError:
|
|
93
|
+
# A real, actionable misconfiguration — the applied schema and the
|
|
94
|
+
# code disagree. Retrying without an operator noticing would only
|
|
95
|
+
# hide the problem, so this is the one case the process actually
|
|
96
|
+
# refuses to start over (D-45, FR-128).
|
|
97
|
+
raise
|
|
98
|
+
except (asyncpg.PostgresError, TimeoutError, OSError) as exc:
|
|
99
|
+
# The database is unreachable, not merely mismatched — a different
|
|
100
|
+
# failure with a different correct response. Per I7, the API still
|
|
101
|
+
# starts: GET /api/health must be reachable in order to REPORT the
|
|
102
|
+
# outage as 503, which it cannot do if the process never boots.
|
|
103
|
+
# health.py re-derives the schema comparison on every request once
|
|
104
|
+
# the database is reachable, so a mismatch discovered later is
|
|
105
|
+
# still surfaced as `degraded`, just later than at boot.
|
|
106
|
+
logger.warning("database unreachable at startup; starting in a degraded state: %s", exc)
|
|
107
|
+
|
|
108
|
+
from anchor.api.serializers.rollup import run_rollup_once
|
|
109
|
+
from anchor.api.ws.orphan_watcher import watch_for_orphans
|
|
110
|
+
from anchor.api.ws.subscriber import run_subscriber
|
|
111
|
+
from anchor.chaos.harness import mark_abandoned_chaos_runs
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
async with pool.acquire(timeout=5.0) as conn:
|
|
115
|
+
abandoned = await mark_abandoned_chaos_runs(conn)
|
|
116
|
+
if abandoned:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"marked stale chaos runs abandoned at startup", extra={"count": abandoned}
|
|
119
|
+
)
|
|
120
|
+
except (asyncpg.PostgresError, TimeoutError, OSError) as exc:
|
|
121
|
+
# Same posture as the schema check above: the database being
|
|
122
|
+
# unreachable at boot is not this check's problem to solve, and
|
|
123
|
+
# the API still starts (I7) — a chaos run left `running` with a
|
|
124
|
+
# stale heartbeat is reconciled the next time this succeeds.
|
|
125
|
+
logger.warning("could not check for abandoned chaos runs at startup: %s", exc)
|
|
126
|
+
|
|
127
|
+
async def _rollup_forever() -> None:
|
|
128
|
+
# Periodic, never a trigger on the append path (D-49) — see
|
|
129
|
+
# anchor.api.serializers.rollup's module docstring for why.
|
|
130
|
+
while True:
|
|
131
|
+
await asyncio.sleep(10.0)
|
|
132
|
+
try:
|
|
133
|
+
async with pool.acquire() as conn:
|
|
134
|
+
await run_rollup_once(conn)
|
|
135
|
+
except (asyncpg.PostgresError, OSError, TimeoutError) as exc:
|
|
136
|
+
logger.warning("metrics rollup tick failed", extra={"error": str(exc)})
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
live = None
|
|
140
|
+
async with pool.acquire(timeout=5.0) as conn:
|
|
141
|
+
with contextlib.suppress(asyncpg.PostgresError, TimeoutError, OSError, KeyError):
|
|
142
|
+
live = await load_live_settings(conn)
|
|
143
|
+
except (asyncpg.PostgresError, TimeoutError, OSError):
|
|
144
|
+
live = None
|
|
145
|
+
|
|
146
|
+
background_tasks.append(
|
|
147
|
+
asyncio.create_task(run_subscriber(redis_client, app.state), name="ws-redis-subscriber")
|
|
148
|
+
)
|
|
149
|
+
background_tasks.append(
|
|
150
|
+
asyncio.create_task(watch_for_orphans(pool, app.state.ws_hub), name="ws-orphan-watcher")
|
|
151
|
+
)
|
|
152
|
+
background_tasks.append(asyncio.create_task(_rollup_forever(), name="metrics-rollup"))
|
|
153
|
+
if live is not None:
|
|
154
|
+
background_tasks.append(
|
|
155
|
+
asyncio.create_task(
|
|
156
|
+
poll_forever(pool, live, redis_client=redis_client), name="live-config-poll"
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
yield
|
|
162
|
+
finally:
|
|
163
|
+
chaos_tasks: set[asyncio.Task[None]] = app.state.chaos_tasks
|
|
164
|
+
for task in (*background_tasks, *chaos_tasks):
|
|
165
|
+
task.cancel()
|
|
166
|
+
for task in (*background_tasks, *chaos_tasks):
|
|
167
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
168
|
+
await task
|
|
169
|
+
await redis_client.aclose()
|
|
170
|
+
await pool.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def create_app() -> FastAPI:
|
|
174
|
+
app = FastAPI(title="Anchor", lifespan=lifespan)
|
|
175
|
+
|
|
176
|
+
from anchor.api.middleware import log_requests, rate_limit_requests
|
|
177
|
+
from anchor.api.routers import (
|
|
178
|
+
authoring,
|
|
179
|
+
chaos,
|
|
180
|
+
config,
|
|
181
|
+
health,
|
|
182
|
+
observability,
|
|
183
|
+
registry,
|
|
184
|
+
runs,
|
|
185
|
+
workers,
|
|
186
|
+
)
|
|
187
|
+
from anchor.api.ws import fleet as fleet_ws
|
|
188
|
+
from anchor.api.ws import runs as runs_ws
|
|
189
|
+
from anchor.runtime.agents import register_all
|
|
190
|
+
|
|
191
|
+
register_all()
|
|
192
|
+
# Order matters: middleware is applied outermost-registered-last, so
|
|
193
|
+
# registering rate limiting after logging means an over-limit request
|
|
194
|
+
# is still logged (T359) before being rejected. CORSMiddleware is added
|
|
195
|
+
# last so it wraps outermost, handling preflights immediately and attaching
|
|
196
|
+
# headers to every response.
|
|
197
|
+
app.middleware("http")(rate_limit_requests)
|
|
198
|
+
app.middleware("http")(log_requests)
|
|
199
|
+
app.add_middleware(
|
|
200
|
+
CORSMiddleware,
|
|
201
|
+
allow_origins=["*"],
|
|
202
|
+
allow_credentials=True,
|
|
203
|
+
allow_methods=["*"],
|
|
204
|
+
allow_headers=["*"],
|
|
205
|
+
expose_headers=["*"],
|
|
206
|
+
)
|
|
207
|
+
app.include_router(health.router)
|
|
208
|
+
app.include_router(runs.router)
|
|
209
|
+
app.include_router(workers.router)
|
|
210
|
+
app.include_router(chaos.router)
|
|
211
|
+
app.include_router(registry.router)
|
|
212
|
+
app.include_router(observability.router)
|
|
213
|
+
app.include_router(config.router)
|
|
214
|
+
app.include_router(authoring.router)
|
|
215
|
+
app.include_router(runs_ws.router)
|
|
216
|
+
app.include_router(fleet_ws.router)
|
|
217
|
+
|
|
218
|
+
# PATCH /api/config is mounted only in local mode — a 404 in
|
|
219
|
+
# demonstration mode, never a 403 (§31.2, FR-064): see
|
|
220
|
+
# anchor.api.routers.config's module docstring for why that
|
|
221
|
+
# distinction matters. Read the one flag directly from the environment
|
|
222
|
+
# here, at app-construction time, rather than via `BootstrapEnv`
|
|
223
|
+
# (which also requires `database_url`/`redis_url` to be set just to
|
|
224
|
+
# decide which routes exist — a requirement this decision doesn't need
|
|
225
|
+
# and that would make importing this module fail in, e.g., a test
|
|
226
|
+
# process that configures the database only inside a fixture) or from
|
|
227
|
+
# `app.state.deployment_mode` (set inside `lifespan`, which has not run
|
|
228
|
+
# yet when routers are mounted).
|
|
229
|
+
if os.environ.get("ANCHOR_AUTHORING_EXECUTE", "false").strip().lower() in (
|
|
230
|
+
"1",
|
|
231
|
+
"true",
|
|
232
|
+
"yes",
|
|
233
|
+
"on",
|
|
234
|
+
):
|
|
235
|
+
app.include_router(config.admin_router)
|
|
236
|
+
# POST /api/authoring/register is the RCE boundary named in
|
|
237
|
+
# contracts/openapi.yaml and quickstart.md V11: unmounted in
|
|
238
|
+
# demonstration mode means a 404, not a permission check, and the
|
|
239
|
+
# handler module that imports registry-mutation code
|
|
240
|
+
# (anchor.api.authoring.register) is imported for the first time
|
|
241
|
+
# right here — see that module's docstring for why no import path
|
|
242
|
+
# from an unconditionally-mounted router reaches it.
|
|
243
|
+
app.include_router(authoring.admin_router)
|
|
244
|
+
|
|
245
|
+
for error_type, (status_code, error_code) in _ERROR_STATUS_CODES.items():
|
|
246
|
+
|
|
247
|
+
def _make_handler(
|
|
248
|
+
code: int, machine_code: str
|
|
249
|
+
) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
|
|
250
|
+
async def _handler(request: Request, exc: Exception) -> JSONResponse:
|
|
251
|
+
# Every one of these five typed errors carries its own
|
|
252
|
+
# structured attributes (run_id, stale_epoch, relationship,
|
|
253
|
+
# offending_values, ...) beyond the message `str(exc)`
|
|
254
|
+
# already renders — surfaced here as `detail` so a caller
|
|
255
|
+
# can act on the specifics, not just display the sentence.
|
|
256
|
+
detail = {
|
|
257
|
+
key: value
|
|
258
|
+
for key, value in vars(exc).items()
|
|
259
|
+
if not key.startswith("_") and key != "args"
|
|
260
|
+
}
|
|
261
|
+
return JSONResponse(
|
|
262
|
+
status_code=code,
|
|
263
|
+
content={"error": machine_code, "message": str(exc), "detail": detail},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return _handler
|
|
267
|
+
|
|
268
|
+
app.add_exception_handler(error_type, _make_handler(status_code, error_code))
|
|
269
|
+
|
|
270
|
+
@app.exception_handler(StarletteHTTPException)
|
|
271
|
+
async def _http_exception_handler(
|
|
272
|
+
request: Request, exc: StarletteHTTPException
|
|
273
|
+
) -> JSONResponse:
|
|
274
|
+
"""Reshapes every `raise HTTPException(...)` in this codebase into
|
|
275
|
+
`contracts/openapi.yaml`'s `Error` (`{error, message, detail}`) —
|
|
276
|
+
see `anchor.api.errors`'s module docstring for why this is a
|
|
277
|
+
global handler rather than a rewrite of every individual raise
|
|
278
|
+
site. `ApiError` (a `HTTPException` subclass) already carries a
|
|
279
|
+
`{error, message}` dict as `.detail`; a plain `HTTPException` with
|
|
280
|
+
a string `.detail` gets a status-derived default `error` code
|
|
281
|
+
instead.
|
|
282
|
+
"""
|
|
283
|
+
if isinstance(exc.detail, dict) and "error" in exc.detail and "message" in exc.detail:
|
|
284
|
+
body = exc.detail
|
|
285
|
+
else:
|
|
286
|
+
body = error_body(exc.status_code, str(exc.detail))
|
|
287
|
+
return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
|
|
288
|
+
|
|
289
|
+
@app.exception_handler(RequestValidationError)
|
|
290
|
+
async def _validation_exception_handler(
|
|
291
|
+
request: Request, exc: RequestValidationError
|
|
292
|
+
) -> JSONResponse:
|
|
293
|
+
"""FastAPI's own request-body/query-param validation failures
|
|
294
|
+
raise this, not `HTTPException` — a separate handler is required
|
|
295
|
+
for the same `Error` shape to cover it (contracts/openapi.yaml
|
|
296
|
+
`ValidationError` response).
|
|
297
|
+
"""
|
|
298
|
+
return JSONResponse(
|
|
299
|
+
status_code=422,
|
|
300
|
+
content={
|
|
301
|
+
"error": "validation_error",
|
|
302
|
+
"message": "request validation failed",
|
|
303
|
+
"detail": {"errors": exc.errors()},
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return app
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
app = create_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The six static checks `validator.py` runs over a draft (plan.md P9.1)."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The determinism-imports check (plan.md P9.1, T566).
|
|
2
|
+
|
|
3
|
+
Reuses `anchor.core.determinism.ast_check.check_source` unmodified — the
|
|
4
|
+
same AST walk that runs as a required test against every module under
|
|
5
|
+
`anchor/runtime/agents/` (constitution Principle III) runs here
|
|
6
|
+
interactively, against a draft that has never executed, so there is
|
|
7
|
+
exactly one implementation of "what counts as a banned reference" to keep
|
|
8
|
+
correct (D-27).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from anchor.api.authoring.messages import determinism_message
|
|
14
|
+
from anchor.api.authoring.models import Finding
|
|
15
|
+
from anchor.core.determinism.ast_check import check_source
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check(source: str) -> list[Finding]:
|
|
19
|
+
findings = check_source(source, module_path="<draft>")
|
|
20
|
+
return [
|
|
21
|
+
Finding(
|
|
22
|
+
check="determinism_imports",
|
|
23
|
+
line=f.line,
|
|
24
|
+
column=f.column,
|
|
25
|
+
message=determinism_message(f.line, f.column, f.banned_name, f.replacement),
|
|
26
|
+
)
|
|
27
|
+
for f in findings
|
|
28
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The module-level mutable state check (plan.md P9.1, T568;
|
|
2
|
+
agent-contract.md rule 4).
|
|
3
|
+
|
|
4
|
+
State held outside `ctx` does not survive a handoff and is the most likely
|
|
5
|
+
authoring mistake — the runtime reconstructs `ctx` fresh from the journal
|
|
6
|
+
on every attempt, but a module-level variable is process memory, not
|
|
7
|
+
journal state.
|
|
8
|
+
|
|
9
|
+
Two static signals catch the common cases without requiring full
|
|
10
|
+
alias/points-to analysis:
|
|
11
|
+
|
|
12
|
+
1. A `global` statement inside any function body — a function cannot
|
|
13
|
+
reassign a module-level name without first declaring `global`, so this
|
|
14
|
+
signal has no false negatives for reassignment and no false positives
|
|
15
|
+
at all.
|
|
16
|
+
2. A call to a mutating method (`.append`, `.extend`, `.update`, `.add`,
|
|
17
|
+
`.pop`, `.remove`, `.clear`, `.discard`, `.popitem`, `.insert`) on a
|
|
18
|
+
bare name that is also assigned a mutable literal (list/dict/set) at
|
|
19
|
+
module level — mutation of a module-level container in place, which
|
|
20
|
+
needs no `global` statement to work and is the second most common form
|
|
21
|
+
of this mistake.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
|
|
28
|
+
from anchor.api.authoring.messages import module_state_message
|
|
29
|
+
from anchor.api.authoring.models import Finding
|
|
30
|
+
|
|
31
|
+
_MUTATING_METHODS = {
|
|
32
|
+
"append",
|
|
33
|
+
"extend",
|
|
34
|
+
"update",
|
|
35
|
+
"add",
|
|
36
|
+
"pop",
|
|
37
|
+
"remove",
|
|
38
|
+
"clear",
|
|
39
|
+
"discard",
|
|
40
|
+
"popitem",
|
|
41
|
+
"insert",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _module_level_mutable_names(tree: ast.Module) -> set[str]:
|
|
46
|
+
names: set[str] = set()
|
|
47
|
+
for node in tree.body:
|
|
48
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.List | ast.Dict | ast.Set):
|
|
49
|
+
for target in node.targets:
|
|
50
|
+
if isinstance(target, ast.Name):
|
|
51
|
+
names.add(target.id)
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check(source: str) -> list[Finding]:
|
|
56
|
+
tree = ast.parse(source)
|
|
57
|
+
findings: list[Finding] = []
|
|
58
|
+
mutable_names = _module_level_mutable_names(tree)
|
|
59
|
+
|
|
60
|
+
for node in ast.walk(tree):
|
|
61
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
62
|
+
for inner in ast.walk(node):
|
|
63
|
+
if isinstance(inner, ast.Global):
|
|
64
|
+
for name in inner.names:
|
|
65
|
+
findings.append(
|
|
66
|
+
Finding(
|
|
67
|
+
check="module_level_mutable_state",
|
|
68
|
+
line=inner.lineno,
|
|
69
|
+
column=inner.col_offset,
|
|
70
|
+
message=module_state_message(inner.lineno, name),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
elif (
|
|
74
|
+
isinstance(inner, ast.Call)
|
|
75
|
+
and isinstance(inner.func, ast.Attribute)
|
|
76
|
+
and inner.func.attr in _MUTATING_METHODS
|
|
77
|
+
and isinstance(inner.func.value, ast.Name)
|
|
78
|
+
and inner.func.value.id in mutable_names
|
|
79
|
+
):
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(
|
|
82
|
+
check="module_level_mutable_state",
|
|
83
|
+
line=inner.lineno,
|
|
84
|
+
column=inner.col_offset,
|
|
85
|
+
message=module_state_message(inner.lineno, inner.func.value.id),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The unbounded-self-recursion check (plan.md P9.1, T571).
|
|
2
|
+
|
|
3
|
+
Catches the trivial infinite-run case: a `decide_next_step` whose every
|
|
4
|
+
reachable `return` calls itself again, with no path that ever returns a
|
|
5
|
+
`ToolCall`, `ModelCall` or `Done`. The attempt cap enforced in phase 6
|
|
6
|
+
(`core.worker` retry/attempt limits) catches everything this check
|
|
7
|
+
misses — a self-call reachable only along one of several branches, for
|
|
8
|
+
instance — so this check is deliberately narrow: it flags only the case
|
|
9
|
+
where *no* branch can ever terminate the loop, which the phase-6 cap
|
|
10
|
+
cannot distinguish from a slow-but-finite draft at authoring time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
|
|
17
|
+
from anchor.api.authoring.messages import self_recursion_message
|
|
18
|
+
from anchor.api.authoring.models import Finding
|
|
19
|
+
|
|
20
|
+
_ACTION_NAMES = {"ToolCall", "ModelCall", "Done"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _call_name(node: ast.expr) -> str | None:
|
|
24
|
+
if isinstance(node, ast.Call):
|
|
25
|
+
func = node.func
|
|
26
|
+
if isinstance(func, ast.Name):
|
|
27
|
+
return func.id
|
|
28
|
+
if isinstance(func, ast.Attribute):
|
|
29
|
+
return func.attr
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(source: str) -> list[Finding]:
|
|
34
|
+
tree = ast.parse(source)
|
|
35
|
+
findings: list[Finding] = []
|
|
36
|
+
for node in ast.walk(tree):
|
|
37
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
38
|
+
continue
|
|
39
|
+
returns = [inner for inner in ast.walk(node) if isinstance(inner, ast.Return)]
|
|
40
|
+
if not returns:
|
|
41
|
+
continue
|
|
42
|
+
names = [_call_name(r.value) if r.value is not None else None for r in returns]
|
|
43
|
+
if not names or any(n in _ACTION_NAMES for n in names):
|
|
44
|
+
continue
|
|
45
|
+
if all(n == node.name for n in names):
|
|
46
|
+
findings.append(
|
|
47
|
+
Finding(
|
|
48
|
+
check="unbounded_self_recursion",
|
|
49
|
+
line=node.lineno,
|
|
50
|
+
column=node.col_offset,
|
|
51
|
+
message=self_recursion_message(node.lineno, node.name),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return findings
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The return-shape check (plan.md P9.1, T567; agent-contract.md rule 5).
|
|
2
|
+
|
|
3
|
+
`decide_next_step` must return/yield exactly one of `ToolCall(...)`,
|
|
4
|
+
`ModelCall(...)` or `Done(...)` on every reachable `return` or `yield`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
|
|
11
|
+
from anchor.api.authoring.messages import return_shape_message
|
|
12
|
+
from anchor.api.authoring.models import Finding
|
|
13
|
+
|
|
14
|
+
_ACTION_NAMES = {"ToolCall", "ModelCall", "Done"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _call_name(node: ast.expr) -> str | None:
|
|
18
|
+
if isinstance(node, ast.Call):
|
|
19
|
+
func = node.func
|
|
20
|
+
if isinstance(func, ast.Name):
|
|
21
|
+
return func.id
|
|
22
|
+
if isinstance(func, ast.Attribute):
|
|
23
|
+
return func.attr
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check(source: str) -> list[Finding]:
|
|
28
|
+
tree = ast.parse(source)
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
for node in ast.walk(tree):
|
|
31
|
+
if not (
|
|
32
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
|
|
33
|
+
and node.name == "decide_next_step"
|
|
34
|
+
):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
# Check if function is a generator (contains yield statements)
|
|
38
|
+
is_generator = any(isinstance(sub, ast.Yield | ast.YieldFrom) for sub in ast.walk(node))
|
|
39
|
+
|
|
40
|
+
for ret in ast.walk(node):
|
|
41
|
+
if isinstance(ret, ast.Yield):
|
|
42
|
+
if ret.value is None:
|
|
43
|
+
continue
|
|
44
|
+
name = _call_name(ret.value)
|
|
45
|
+
if name not in _ACTION_NAMES:
|
|
46
|
+
findings.append(
|
|
47
|
+
Finding(
|
|
48
|
+
check="return_shape",
|
|
49
|
+
line=ret.lineno,
|
|
50
|
+
column=ret.col_offset,
|
|
51
|
+
message=return_shape_message(ret.lineno),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
elif isinstance(ret, ast.Return):
|
|
55
|
+
if ret.value is None:
|
|
56
|
+
if is_generator:
|
|
57
|
+
continue # Empty return in a generator is valid termination
|
|
58
|
+
findings.append(
|
|
59
|
+
Finding(
|
|
60
|
+
check="return_shape",
|
|
61
|
+
line=ret.lineno,
|
|
62
|
+
column=ret.col_offset,
|
|
63
|
+
message=return_shape_message(ret.lineno, empty=True),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
name = _call_name(ret.value)
|
|
68
|
+
if name not in _ACTION_NAMES:
|
|
69
|
+
findings.append(
|
|
70
|
+
Finding(
|
|
71
|
+
check="return_shape",
|
|
72
|
+
line=ret.lineno,
|
|
73
|
+
column=ret.col_offset,
|
|
74
|
+
message=return_shape_message(ret.lineno),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return findings
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The missing-safety-declaration check (plan.md P9.1, T570).
|
|
2
|
+
|
|
3
|
+
A draft may declare a new tool inline with the `@anchor.tool(...)` /
|
|
4
|
+
`@tool(...)` decorator (`docs/tools.md`'s SDK-convenience form of
|
|
5
|
+
`@anchor.tool(safety="retry_safe")`). `anchor.runtime.tools.registry.register`
|
|
6
|
+
already refuses
|
|
7
|
+
a `safety` value outside `retry_safe` / `reconcilable` / `unsafe` at
|
|
8
|
+
registration time (`_validate`), but a missing `safety=` keyword entirely
|
|
9
|
+
is a `TypeError` at decoration time — a mistake worth catching here,
|
|
10
|
+
before the draft is ever imported, because "there is no default to fall
|
|
11
|
+
back to" is exactly the invariant this check exists to teach early.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
|
|
18
|
+
from anchor.api.authoring.messages import missing_safety_message
|
|
19
|
+
from anchor.api.authoring.models import Finding
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check(source: str) -> list[Finding]:
|
|
23
|
+
tree = ast.parse(source)
|
|
24
|
+
findings: list[Finding] = []
|
|
25
|
+
for node in ast.walk(tree):
|
|
26
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
27
|
+
continue
|
|
28
|
+
for decorator in node.decorator_list:
|
|
29
|
+
if not (isinstance(decorator, ast.Call) and _is_tool_decorator(decorator)):
|
|
30
|
+
continue
|
|
31
|
+
has_safety = any(kw.arg == "safety" for kw in decorator.keywords)
|
|
32
|
+
if not has_safety:
|
|
33
|
+
findings.append(
|
|
34
|
+
Finding(
|
|
35
|
+
check="missing_safety_declaration",
|
|
36
|
+
line=decorator.lineno,
|
|
37
|
+
column=decorator.col_offset,
|
|
38
|
+
message=missing_safety_message(decorator.lineno, node.name),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return findings
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_tool_decorator(node: ast.Call) -> bool:
|
|
45
|
+
func = node.func
|
|
46
|
+
if isinstance(func, ast.Name):
|
|
47
|
+
return func.id == "tool"
|
|
48
|
+
if isinstance(func, ast.Attribute):
|
|
49
|
+
return func.attr == "tool"
|
|
50
|
+
return False
|