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.
Files changed (129) hide show
  1. anchor/__init__.py +42 -0
  2. anchor/api/__init__.py +7 -0
  3. anchor/api/app.py +310 -0
  4. anchor/api/authoring/__init__.py +5 -0
  5. anchor/api/authoring/checks/__init__.py +1 -0
  6. anchor/api/authoring/checks/determinism.py +28 -0
  7. anchor/api/authoring/checks/module_state.py +88 -0
  8. anchor/api/authoring/checks/recursion.py +54 -0
  9. anchor/api/authoring/checks/return_shape.py +78 -0
  10. anchor/api/authoring/checks/safety.py +50 -0
  11. anchor/api/authoring/checks/tool_names.py +49 -0
  12. anchor/api/authoring/messages.py +65 -0
  13. anchor/api/authoring/models.py +69 -0
  14. anchor/api/authoring/register.py +100 -0
  15. anchor/api/authoring/validator.py +56 -0
  16. anchor/api/errors.py +74 -0
  17. anchor/api/middleware.py +167 -0
  18. anchor/api/routers/__init__.py +1 -0
  19. anchor/api/routers/authoring.py +108 -0
  20. anchor/api/routers/chaos.py +257 -0
  21. anchor/api/routers/config.py +152 -0
  22. anchor/api/routers/health.py +155 -0
  23. anchor/api/routers/observability.py +312 -0
  24. anchor/api/routers/registry.py +93 -0
  25. anchor/api/routers/runs.py +622 -0
  26. anchor/api/routers/workers.py +156 -0
  27. anchor/api/serializers/__init__.py +1 -0
  28. anchor/api/serializers/chaos.py +132 -0
  29. anchor/api/serializers/rollup.py +275 -0
  30. anchor/api/serializers/runs.py +114 -0
  31. anchor/api/serializers/timeline.py +308 -0
  32. anchor/api/serializers/workers.py +70 -0
  33. anchor/api/ws/__init__.py +1 -0
  34. anchor/api/ws/backpressure.py +61 -0
  35. anchor/api/ws/fleet.py +85 -0
  36. anchor/api/ws/orphan_watcher.py +70 -0
  37. anchor/api/ws/runs.py +99 -0
  38. anchor/api/ws/subscriber.py +139 -0
  39. anchor/chaos/__init__.py +7 -0
  40. anchor/chaos/chaos_worker.py +98 -0
  41. anchor/chaos/harness.py +323 -0
  42. anchor/chaos/injections/__init__.py +5 -0
  43. anchor/chaos/injections/kill.py +55 -0
  44. anchor/chaos/injections/latency.py +44 -0
  45. anchor/chaos/injections/stall.py +43 -0
  46. anchor/chaos/injections/tool_failure.py +42 -0
  47. anchor/chaos/injections/uncertainty.py +131 -0
  48. anchor/chaos/invariants.py +308 -0
  49. anchor/chaos/recorder.py +41 -0
  50. anchor/chaos/report.py +265 -0
  51. anchor/chaos/workload.py +52 -0
  52. anchor/cli.py +186 -0
  53. anchor/core/__init__.py +7 -0
  54. anchor/core/config/__init__.py +7 -0
  55. anchor/core/config/assertion.py +61 -0
  56. anchor/core/config/live.py +158 -0
  57. anchor/core/config/loader.py +72 -0
  58. anchor/core/config/profiles.py +77 -0
  59. anchor/core/config/settings.py +75 -0
  60. anchor/core/db/__init__.py +6 -0
  61. anchor/core/db/errors.py +163 -0
  62. anchor/core/db/pool.py +64 -0
  63. anchor/core/db/schema_gate.py +75 -0
  64. anchor/core/determinism/__init__.py +7 -0
  65. anchor/core/determinism/actions.py +39 -0
  66. anchor/core/determinism/ast_check.py +83 -0
  67. anchor/core/determinism/buffer.py +72 -0
  68. anchor/core/determinism/context.py +352 -0
  69. anchor/core/events/__init__.py +7 -0
  70. anchor/core/events/append.py +144 -0
  71. anchor/core/events/models.py +21 -0
  72. anchor/core/events/payloads.py +174 -0
  73. anchor/core/events/publish.py +136 -0
  74. anchor/core/events/types.py +30 -0
  75. anchor/core/journal/__init__.py +7 -0
  76. anchor/core/journal/canonical.py +116 -0
  77. anchor/core/journal/keys.py +50 -0
  78. anchor/core/journal/lookup.py +98 -0
  79. anchor/core/journal/policies.py +324 -0
  80. anchor/core/journal/reconcile.py +44 -0
  81. anchor/core/journal/tool_protocol.py +38 -0
  82. anchor/core/journal/two_phase.py +358 -0
  83. anchor/core/leases/__init__.py +7 -0
  84. anchor/core/leases/claim.py +186 -0
  85. anchor/core/leases/fencing.py +124 -0
  86. anchor/core/leases/renew.py +179 -0
  87. anchor/core/logging.py +40 -0
  88. anchor/core/replay/__init__.py +6 -0
  89. anchor/core/replay/context.py +137 -0
  90. anchor/core/replay/handlers.py +189 -0
  91. anchor/core/replay/load.py +39 -0
  92. anchor/core/replay/reconstruct.py +84 -0
  93. anchor/runner.py +229 -0
  94. anchor/runtime/__init__.py +1 -0
  95. anchor/runtime/agents/__init__.py +87 -0
  96. anchor/runtime/agents/adapter.py +56 -0
  97. anchor/runtime/agents/decorators.py +48 -0
  98. anchor/runtime/agents/demo_chaos_flaky.py +26 -0
  99. anchor/runtime/agents/demo_chaos_latency.py +21 -0
  100. anchor/runtime/agents/demo_long.py +56 -0
  101. anchor/runtime/agents/demo_minimal.py +22 -0
  102. anchor/runtime/agents/demo_short.py +58 -0
  103. anchor/runtime/agents/demo_unsafe.py +31 -0
  104. anchor/runtime/agents/registry.py +87 -0
  105. anchor/runtime/tools/__init__.py +1 -0
  106. anchor/runtime/tools/chaos.py +82 -0
  107. anchor/runtime/tools/decorators.py +86 -0
  108. anchor/runtime/tools/demo.py +306 -0
  109. anchor/runtime/tools/model.py +178 -0
  110. anchor/runtime/tools/registry.py +184 -0
  111. anchor/worker/__init__.py +6 -0
  112. anchor/worker/__main__.py +136 -0
  113. anchor/worker/admission/__init__.py +1 -0
  114. anchor/worker/admission/limiter.py +18 -0
  115. anchor/worker/loop.py +795 -0
  116. anchor/worker/registry/__init__.py +5 -0
  117. anchor/worker/registry/heartbeat.py +87 -0
  118. anchor/worker/registry/identity.py +72 -0
  119. anchor/worker/registry/kill.py +49 -0
  120. anchor/worker/registry/register.py +79 -0
  121. anchor/worker/renewer.py +101 -0
  122. anchor/worker/retry/__init__.py +1 -0
  123. anchor/worker/retry/backoff.py +28 -0
  124. anchor/worker/retry/policy.py +148 -0
  125. anchor_runtime-1.4.2.dist-info/METADATA +16 -0
  126. anchor_runtime-1.4.2.dist-info/RECORD +129 -0
  127. anchor_runtime-1.4.2.dist-info/WHEEL +4 -0
  128. anchor_runtime-1.4.2.dist-info/entry_points.txt +3 -0
  129. 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,5 @@
1
+ """The phase-9 authoring surface: validator, generator, and the gated register route.
2
+
3
+ Deferred to phase 9 (plan.md). Present as an empty package so the import
4
+ path exists once `anchor.api.app` references it.
5
+ """
@@ -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