spatialagents 0.1.0__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.
@@ -0,0 +1,927 @@
1
+ """Test helpers: a stand-in gateway, a stand-in product, a scripted model.
2
+
3
+ Registered as a pytest plugin (`spatialagents = "spatialagents.testing"`) that
4
+ is inert unless a suite asks for one of its fixtures, so installing the
5
+ package changes nothing about a product's test run. No network, no model and
6
+ no product is needed for any of it.
7
+
8
+ `FakeGateway` is a real HTTP server on a loopback port (like
9
+ `biocognia.testing.FakeService`) that speaks the BioCognia AI gateway's wire:
10
+ `/v1/ai/messages` takes only `task` (`<product>.<module>.<task>`), `context`,
11
+ `request` and `model`, as the real one does, and streams Anthropic-shaped
12
+ events between `biocognia.accepted` and `biocognia.usage`, and `/v1/ai/runs`
13
+ quotes and closes runs. The "model" is a brain function given the decision
14
+ packet the harness sent (and the whole request body); it answers with JSON or
15
+ text, with a `Reply` that streams `tool_use` blocks the way a provider does
16
+ (`input_json_delta` pieces, `stop_reason: tool_use`), or with `(status,
17
+ code)` to refuse like the gateway. The usage is a prompt-cache emulator that
18
+ reads from cache the longest prefix of the request it has seen before and
19
+ writes the rest, so cache verdicts and prefix stability can be tested. A
20
+ product's suite subclasses it to name itself (`PRODUCT`: every task must be
21
+ its own) and its run prices (`PRICES`).
22
+
23
+ `FakeToolBackend` is a `backend.ToolBackend` over `FakeTool`s defined in the
24
+ test: the reference backend, and the contract a product's backend is held
25
+ to. `FakePlanHost` is the same for `plans.PlanHost`, and `plan_brain` answers
26
+ its two packets. `ScriptedBrain` answers a conversation's calls from a script,
27
+ or replays a list of tool calls (a plan's steps) one per call, as an oracle.
28
+
29
+ tokens = fixed_tokens() # a token source, no licence
30
+ with FakeGateway(ScriptedBrain([Reply("hi")])) as fake:
31
+ client = GatewayClient(tokens, fake.url, sleep=lambda s: None)
32
+
33
+ Fixtures (all opt-in): `spatialagents_gateway` (start FakeGateways, stopped at
34
+ teardown), `spatialagents_tokens`, `spatialagents_backend` (an empty
35
+ FakeToolBackend to add tools to).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import json
42
+ import os
43
+ import re
44
+ import threading
45
+ from dataclasses import dataclass, field
46
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
+ from types import SimpleNamespace
48
+ from typing import Any, Callable
49
+
50
+ from spatialagents.wire import canonical
51
+
52
+ CHARS_PER_TOKEN = 3.5
53
+ IMAGE_TOKENS = 1600
54
+ #: $4 / $0.20 / $5 / $20 per MTok, as Opus 5.5 on the real gateway.
55
+ UNIT = {"in": 4.0, "read": 0.2, "write": 5.0, "out": 20.0}
56
+
57
+
58
+ def _marked(value) -> bool:
59
+ if isinstance(value, dict):
60
+ return "cache_control" in value or any(_marked(v) for v in value.values())
61
+ if isinstance(value, list):
62
+ return any(_marked(v) for v in value)
63
+ return False
64
+
65
+
66
+ def _unmarked(value):
67
+ """`value` without cache_control keys: a provider matches a cached prefix by
68
+ content, wherever the breakpoint sat on the call that wrote it."""
69
+ if isinstance(value, dict):
70
+ return {k: _unmarked(v) for k, v in value.items() if k != "cache_control"}
71
+ if isinstance(value, list):
72
+ return [_unmarked(v) for v in value]
73
+ return value
74
+
75
+
76
+ def _tokens(value) -> int:
77
+ if isinstance(value, dict):
78
+ if value.get("type") == "image":
79
+ return IMAGE_TOKENS
80
+ if value.get("type") == "text":
81
+ return max(1, int(len(value.get("text", "")) / CHARS_PER_TOKEN))
82
+ if isinstance(value, list):
83
+ return sum(_tokens(v) for v in value)
84
+ if isinstance(value, dict):
85
+ return _tokens(value.get("content", []))
86
+ return max(1, int(len(str(value)) / CHARS_PER_TOKEN))
87
+
88
+
89
+ class Reply:
90
+ """A brain's answer with tool calls: optional text, then `tool_use` blocks
91
+ (`{"name", "input", "id"?}`), streamed as a provider streams them."""
92
+
93
+ def __init__(self, text: str = "", tool_uses=()):
94
+ self.text = text
95
+ self.tool_uses = [dict(t) for t in tool_uses]
96
+
97
+ def __repr__(self):
98
+ return f"Reply({self.text!r}, {self.tool_uses!r})"
99
+
100
+
101
+ def tool_results(body: dict) -> list:
102
+ """The `tool_result` blocks of the newest user turn of a request body."""
103
+ messages = body["request"]["messages"]
104
+ if not messages or messages[-1].get("role") != "user":
105
+ return []
106
+ content = messages[-1].get("content")
107
+ return [b for b in (content if isinstance(content, list) else []) if b.get("type") == "tool_result"]
108
+
109
+
110
+ def result_text(block: dict) -> str:
111
+ content = block.get("content")
112
+ if isinstance(content, str):
113
+ return content
114
+ return "".join(b.get("text", "") for b in content or [] if b.get("type") == "text")
115
+
116
+
117
+ def last_user_text(body: dict) -> str:
118
+ for message in reversed(body["request"]["messages"]):
119
+ if message.get("role") != "user":
120
+ continue
121
+ content = message.get("content")
122
+ if isinstance(content, str):
123
+ return content
124
+ texts = [b.get("text", "") for b in content or [] if b.get("type") == "text"]
125
+ if texts:
126
+ return texts[-1]
127
+ return ""
128
+
129
+
130
+ def last_packet(messages: list) -> dict | None:
131
+ """The newest decision packet among the user turns (skipping repair turns):
132
+ a JSON object with a `packet_id`, optionally followed by `ANSWER SCHEMA:`."""
133
+ for message in reversed(messages):
134
+ if message.get("role") != "user":
135
+ continue
136
+ content = message.get("content") or []
137
+ for block in reversed(content if isinstance(content, list) else []):
138
+ if block.get("type") == "text":
139
+ try:
140
+ value = json.loads(block["text"].split("\nANSWER SCHEMA:")[0])
141
+ except ValueError:
142
+ break
143
+ if isinstance(value, dict) and "packet_id" in value:
144
+ return value
145
+ return None
146
+
147
+
148
+ #: What the real gateway accepts at the top of a messages body (workers/ai TOP_KEYS).
149
+ WIRE_KEYS = {"task", "context", "request", "model"}
150
+ WIRE_TASK = re.compile(r"^[a-z][a-z0-9_]{1,31}\.[a-z][a-z0-9_]{0,31}\.[a-z][a-z0-9_]{0,31}$")
151
+
152
+
153
+ class FakeGateway:
154
+ #: The product every task must be filed under (`<PRODUCT>.<module>.<task>`), or None for any.
155
+ PRODUCT: str | None = None
156
+ #: Run prices by feature, `{feature: (unit, credits per unit)}` (the real gateway's catalogue).
157
+ PRICES: dict = {}
158
+ #: A feature the price list does not name.
159
+ DEFAULT_PRICE = ("unit", 25)
160
+
161
+ def __init__(self, brain, *, credits_micro: int = 10_000_000, markup: float = 2.0):
162
+ self.brain = brain
163
+ self.calls: list[dict] = []
164
+ self.runs: dict[str, dict] = {}
165
+ self.seen_prefixes: set[str] = set()
166
+ self.credits = credits_micro
167
+ self.markup = markup
168
+ self.fail_with: list[tuple[int, str]] = [] # queued (status, code) refusals
169
+ self.lock = threading.Lock()
170
+ self._offline = None
171
+ gateway = self
172
+
173
+ class Handler(BaseHTTPRequestHandler):
174
+ def log_message(self, *args):
175
+ pass
176
+
177
+ def _json(self, status, body, headers=None):
178
+ data = json.dumps(body).encode()
179
+ self.send_response(status)
180
+ self.send_header("Content-Type", "application/json")
181
+ self.send_header("Content-Length", str(len(data)))
182
+ for k, v in (headers or {}).items():
183
+ self.send_header(k, v)
184
+ self.end_headers()
185
+ self.wfile.write(data)
186
+
187
+ def _body(self):
188
+ length = int(self.headers.get("Content-Length") or 0)
189
+ return json.loads(self.rfile.read(length) or b"{}")
190
+
191
+ def do_GET(self):
192
+ if self.path.startswith("/v1/ai/balance"):
193
+ return self._json(200, {"org_id": "org_test", "product": gateway.PRODUCT or "test",
194
+ "mode": "credits", "available_micro": gateway.credits,
195
+ "member": {"limit": None, "used": 0, "period": "2026-10"},
196
+ "product_cap": {"limit": None, "used": 0, "period": "2026-10"},
197
+ "action_url": "https://account.biocognia.test/portal/ai",
198
+ "action_label": "Add credits or ask an owner"})
199
+ if self.path.startswith("/v1/ai/pricing"):
200
+ return self._json(200, {"credit_micro": 10_000, "features": {
201
+ name: {"unit": unit, "credits": credits, "price_micro": credits * 10_000}
202
+ for name, (unit, credits) in gateway.PRICES.items()}})
203
+ return self._json(404, {"error": {"code": "not_found", "message": "no"}})
204
+
205
+ def do_POST(self):
206
+ body = self._body()
207
+ if self.path in ("/v1/ai/runs", "/v1/ai/dev/runs"):
208
+ run_id = f"run_{len(gateway.runs) + 1}"
209
+ credits = gateway.PRICES.get(body["feature"], gateway.DEFAULT_PRICE)[1]
210
+ run = {"run_id": run_id, "feature": body["feature"], "units": body["units"],
211
+ "quote_micro": body["units"] * credits * 10_000,
212
+ "quote_credits": body["units"] * credits,
213
+ "accrued_micro": 0, "charged_micro": 0, "calls": 0, "status": "open",
214
+ "billing": "dev" if "/dev/" in self.path else "credits"}
215
+ gateway.runs[run_id] = run
216
+ return self._json(201, run)
217
+ if self.path.startswith("/v1/ai/runs/") and self.path.endswith("/finish"):
218
+ run = gateway.runs[self.path.split("/")[4]]
219
+ run["status"] = "finished"
220
+ return self._json(200, run)
221
+ if self.path in ("/v1/ai/messages", "/v1/ai/dev/messages"):
222
+ return gateway._messages(self, body)
223
+ return self._json(404, {"error": {"code": "not_found", "message": "no"}})
224
+
225
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
226
+ self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
227
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
228
+
229
+ def __enter__(self):
230
+ # A suite runs under BIOCOGNIA_OFFLINE=1, which the shared gateway
231
+ # client honours before opening any socket; this gateway is on
232
+ # loopback, so it is switched off while the fake runs.
233
+ self._offline = os.environ.pop("BIOCOGNIA_OFFLINE", None)
234
+ self.thread.start()
235
+ return self
236
+
237
+ def __exit__(self, *exc):
238
+ self.server.shutdown()
239
+ self.server.server_close()
240
+ if self._offline is not None:
241
+ os.environ["BIOCOGNIA_OFFLINE"] = self._offline
242
+
243
+ # -- the model call ---------------------------------------------------------
244
+
245
+ def _usage(self, request: dict) -> dict:
246
+ """Prompt-cache emulation, Anthropic-style: segments are the tools, the
247
+ system prompt, then each message; a prefix is stored only where a
248
+ cache_control breakpoint ends it, and the longest stored prefix is read,
249
+ the rest written. So a request that marks only its system prompt never
250
+ reads its earlier turns from cache, as on the real API."""
251
+ segments = (([request["tools"]] if request.get("tools") else []) + [request.get("system") or []]
252
+ + list(request["messages"]))
253
+ running = hashlib.sha256()
254
+ keys = []
255
+ for segment in segments:
256
+ running.update(canonical(_unmarked(segment)).encode())
257
+ keys.append(running.hexdigest())
258
+ tokens = [_tokens(segment) for segment in segments]
259
+ with self.lock:
260
+ hit = max((i + 1 for i, key in enumerate(keys) if key in self.seen_prefixes), default=0)
261
+ for segment, key in zip(segments, keys):
262
+ if _marked(segment):
263
+ self.seen_prefixes.add(key)
264
+ return {"input_uncached": 3, "cache_read": sum(tokens[:hit]), "cache_write_5m": sum(tokens[hit:]),
265
+ "cache_write_1h": 0}
266
+
267
+ def _messages(self, handler, body: dict):
268
+ unknown = set(body) - WIRE_KEYS
269
+ if unknown:
270
+ return handler._json(400, {"error": {"code": "invalid_request",
271
+ "message": f"Unknown field `{sorted(unknown)[0]}`."}})
272
+ if not isinstance(body.get("task"), str) or not WIRE_TASK.match(body["task"]) \
273
+ or (self.PRODUCT is not None and not body["task"].startswith(f"{self.PRODUCT}.")):
274
+ return handler._json(400, {"error": {"code": "invalid_request",
275
+ "message": "`task` is <product>.<module>.<task>."}})
276
+ with self.lock:
277
+ refusal = self.fail_with.pop(0) if self.fail_with else None
278
+ if refusal:
279
+ status, code = refusal
280
+ return handler._json(status, {"error": {"code": code, "message": code, "retry_after": 0}})
281
+ request = body["request"]
282
+ packet = last_packet(request["messages"])
283
+ answer = self.brain(packet, body)
284
+ if isinstance(answer, tuple) and len(answer) == 2 and isinstance(answer[0], int):
285
+ status, code = answer # a brain may refuse, like the gateway
286
+ return handler._json(status, {"error": {"code": code, "message": code, "retry_after": 0}})
287
+ tool_uses = []
288
+ if isinstance(answer, Reply):
289
+ text, tool_uses = answer.text, answer.tool_uses
290
+ else:
291
+ text = answer if isinstance(answer, str) else canonical(answer)
292
+ usage = self._usage(request)
293
+ usage["output_tokens"] = max(1, int((len(text) + len(canonical(tool_uses))) / CHARS_PER_TOKEN))
294
+ cost = int((usage["input_uncached"] * UNIT["in"] + usage["cache_read"] * UNIT["read"]
295
+ + usage["cache_write_5m"] * UNIT["write"] + usage["output_tokens"] * UNIT["out"]))
296
+ dev = handler.path.startswith("/v1/ai/dev/")
297
+ price = cost if dev else int(cost * self.markup)
298
+ run = self.runs.get((body.get("context") or {}).get("run_id") or "")
299
+ with self.lock:
300
+ charged = price
301
+ if run:
302
+ accrued = run["accrued_micro"] + price
303
+ charged = max(0, min(accrued, run["quote_micro"]) - run["charged_micro"])
304
+ run["accrued_micro"], run["charged_micro"] = accrued, run["charged_micro"] + charged
305
+ run["calls"] += 1
306
+ self.credits -= charged
307
+ number = len(self.calls) + 1
308
+ self.calls.append({"path": handler.path, "body": body, "tool_uses": tool_uses,
309
+ "idempotency_key": handler.headers.get("Idempotency-Key"),
310
+ "usage": usage, "packet_id": (packet or {}).get("packet_id")})
311
+ rid = f"req_{number}"
312
+ content_events = []
313
+ index = 0
314
+ if text or not tool_uses:
315
+ content_events += [
316
+ ("content_block_start", {"type": "content_block_start", "index": 0,
317
+ "content_block": {"type": "text", "text": ""}}),
318
+ ("content_block_delta", {"type": "content_block_delta", "index": 0,
319
+ "delta": {"type": "text_delta", "text": text[: len(text) // 2]}}),
320
+ ("content_block_delta", {"type": "content_block_delta", "index": 0,
321
+ "delta": {"type": "text_delta", "text": text[len(text) // 2:]}}),
322
+ ("content_block_stop", {"type": "content_block_stop", "index": 0})]
323
+ index = 1
324
+ for n, use in enumerate(tool_uses):
325
+ raw = json.dumps(use.get("input") or {})
326
+ tid = use.get("id") or f"toolu_{number}_{n}"
327
+ content_events += [
328
+ ("content_block_start", {"type": "content_block_start", "index": index, "content_block": {
329
+ "type": "tool_use", "id": tid, "name": use["name"], "input": {}}}),
330
+ ("content_block_delta", {"type": "content_block_delta", "index": index,
331
+ "delta": {"type": "input_json_delta", "partial_json": raw[: len(raw) // 2]}}),
332
+ ("content_block_delta", {"type": "content_block_delta", "index": index,
333
+ "delta": {"type": "input_json_delta", "partial_json": raw[len(raw) // 2:]}}),
334
+ ("content_block_stop", {"type": "content_block_stop", "index": index})]
335
+ index += 1
336
+ events = [
337
+ ("biocognia.accepted", {"gateway_request_id": rid, "billing": "dev" if dev else "credits"}),
338
+ ("message_start", {"type": "message_start", "message": {"id": f"msg_{number}", "usage": {
339
+ "input_tokens": usage["input_uncached"], "cache_read_input_tokens": usage["cache_read"],
340
+ "cache_creation_input_tokens": usage["cache_write_5m"], "output_tokens": 1}}}),
341
+ *content_events,
342
+ ("message_delta", {"type": "message_delta",
343
+ "delta": {"stop_reason": "tool_use" if tool_uses else "end_turn"},
344
+ "usage": {"output_tokens": usage["output_tokens"]}}),
345
+ ("message_stop", {"type": "message_stop"}),
346
+ ("biocognia.usage", {"gateway_request_id": rid, "status": "ok", "usage_source": "provider",
347
+ "usage": usage, "price_micro": price, "charged_micro": charged,
348
+ "billing": "dev" if dev else "credits", "model": "approved-model-a",
349
+ "provider": "provider-x",
350
+ **({"cost_micro": cost} if dev else {}),
351
+ "run": dict(run) if run else None,
352
+ "balance": {"available_micro": self.credits}}),
353
+ ]
354
+ handler.send_response(200)
355
+ handler.send_header("Content-Type", "text/event-stream")
356
+ handler.end_headers()
357
+ for name, data in events:
358
+ handler.wfile.write(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode())
359
+ handler.wfile.flush()
360
+
361
+
362
+ # -- a token source without a licence --------------------------------------------------------
363
+
364
+
365
+ def fixed_tokens(product: str = "testapp", token: str = "BIOCAI1.test", *, version: str = "0.0.0"):
366
+ """A `biocognia.gateway.TokenSource` that always answers `token`, for a
367
+ product with this id: what a gateway client in a test is given."""
368
+ from biocognia.gateway import TokenSource
369
+
370
+ licensing = SimpleNamespace(product=SimpleNamespace(id=product, name=product, cli=product, version=version))
371
+ return TokenSource(licensing, token)
372
+
373
+
374
+ def client(fake: "FakeGateway", *, product: str = "testapp", **options):
375
+ """A `gateway.GatewayClient` for `fake`, with no waiting between retries."""
376
+ from spatialagents.gateway import GatewayClient
377
+
378
+ options.setdefault("sleep", lambda seconds: None)
379
+ return GatewayClient(fixed_tokens(product), fake.url, **options)
380
+
381
+
382
+ # -- a stand-in product ------------------------------------------------------------------------
383
+
384
+
385
+ class ToolFailure(Exception):
386
+ """Raised by a `FakeTool` handler: the call fails with this code and message."""
387
+
388
+ def __init__(self, code: str, message: str, detail=None):
389
+ super().__init__(message)
390
+ self.code = code
391
+ self.message = message
392
+ self.detail = detail
393
+
394
+
395
+ @dataclass
396
+ class FakeTool:
397
+ """One tool of a `FakeToolBackend`.
398
+
399
+ `handler(arguments)` returns the result (a dict may carry `_images`, and a
400
+ write's `_receipt` -- `{"undo": bool, "advanced": {...}}` -- which is lifted
401
+ into the outcome); raising `ToolFailure` fails the call with its code, any
402
+ other exception as `internal_error`. `permission` is a class or a function
403
+ of the arguments (a tool whose class depends on them). `input_schema` is a
404
+ JSON schema; its `required` fields are checked, and so are the types
405
+ `integer`, `number`, `string` and `boolean` of its properties."""
406
+
407
+ name: str
408
+ handler: Callable[[dict], Any]
409
+ purpose: str = ""
410
+ permission: str | Callable[[dict], str] = "read"
411
+ input_schema: dict = field(default_factory=lambda: {"type": "object", "properties": {}})
412
+ #: None: cacheable when it reads.
413
+ cacheable: bool | None = None
414
+ egress: str = "aggregates"
415
+ viewer: bool = False
416
+ capability: str | None = None
417
+
418
+ def permission_for(self, arguments: dict) -> str:
419
+ return self.permission(arguments) if callable(self.permission) else self.permission
420
+
421
+
422
+ _TYPES = {"integer": int, "number": (int, float), "string": str, "boolean": bool}
423
+
424
+
425
+ class FakeToolBackend:
426
+ """A `ToolBackend` over the `FakeTool`s a test defines.
427
+
428
+ It checks a call as a product's own surface would: the arguments against
429
+ the schema, the policy's egress and write permissions, and for a
430
+ source-file write or a delete the policy flag AND the person's approval
431
+ (`approved=True`) -- so an agent that never asked is refused with
432
+ `permission_required`. Every successful write moves `revision` on (what
433
+ `revision_token` answers), so a remembered read is invalidated by any write.
434
+ `calls` records every invoke: `(tool, arguments, approved)`."""
435
+
436
+ def __init__(self, tools=(), *, skills: dict | None = None, info: dict | None = None,
437
+ revision_guard: tuple[str, str] | None = None, max_result_chars: int | None = None):
438
+ from spatialagents import serialize
439
+
440
+ self.tools: dict[str, FakeTool] = {}
441
+ for tool in tools:
442
+ self.add(tool)
443
+ self.skill_texts = dict(skills or {})
444
+ self.info = dict(info or {})
445
+ self.revision_guard = revision_guard
446
+ self.max_result_chars = max_result_chars or serialize.MAX_RESULT_CHARS
447
+ self.revision = 0
448
+ self.calls: list[tuple] = []
449
+ self._ops = 0
450
+ self._lock = threading.Lock()
451
+
452
+ def add(self, tool: FakeTool) -> FakeTool:
453
+ self.tools[tool.name] = tool
454
+ return tool
455
+
456
+ def tool(self, name: str, *, purpose: str = "", permission="read", schema: dict | None = None, **kw):
457
+ """Decorator: the function becomes a tool's handler."""
458
+ def register(handler):
459
+ self.add(FakeTool(name, handler, purpose=purpose, permission=permission,
460
+ input_schema=schema or {"type": "object", "properties": {}}, **kw))
461
+ return handler
462
+ return register
463
+
464
+ # -- the protocol ----------------------------------------------------------------
465
+
466
+ def catalog(self, policy, *, viewer: bool = False) -> list[dict]:
467
+ out = []
468
+ for tool in self.tools.values():
469
+ permission = tool.permission_for({})
470
+ if tool.egress not in policy.egress:
471
+ continue
472
+ if permission != "read" and not policy.allow_writes:
473
+ continue
474
+ if tool.viewer and not viewer:
475
+ continue
476
+ out.append({"tool": tool.name, "capability": tool.capability or f"fake.{tool.name}",
477
+ "permission": permission, "purpose": tool.purpose})
478
+ return out
479
+
480
+ def definition(self, tool: str) -> dict:
481
+ spec = self.tools[tool]
482
+ return {"name": spec.name, "description": spec.purpose, "input_schema": json.loads(json.dumps(
483
+ spec.input_schema))}
484
+
485
+ def permission(self, tool: str, arguments: dict) -> str:
486
+ spec = self.tools.get(tool)
487
+ return spec.permission_for(arguments) if spec else "read"
488
+
489
+ def purpose(self, tool: str) -> str:
490
+ spec = self.tools.get(tool)
491
+ return spec.purpose if spec else ""
492
+
493
+ def cacheable(self, tool: str, arguments: dict) -> bool:
494
+ spec = self.tools.get(tool)
495
+ if spec is None:
496
+ return False
497
+ if spec.cacheable is not None:
498
+ return spec.cacheable
499
+ return self.permission(tool, arguments) == "read" and spec.egress not in ("row_level", "raw_pixels") \
500
+ and not spec.viewer
501
+
502
+ def revision_token(self, tool: str, arguments: dict) -> str:
503
+ return str(self.revision)
504
+
505
+ def skills(self) -> list[dict]:
506
+ return [{"name": name, "title": name.replace("-", " ").title(), "when": f"before {name} work"}
507
+ for name in self.skill_texts]
508
+
509
+ def read_skill(self, name: str) -> str:
510
+ if name not in self.skill_texts:
511
+ raise KeyError(f"no skill {name!r}")
512
+ return self.skill_texts[name]
513
+
514
+ def server_info(self, policy, *, tools: int, viewer: bool) -> dict:
515
+ return {"product": "fake", **self.info, "policy": policy.describe(), "n_tools": tools,
516
+ "viewer": viewer, "skills": [s["name"] for s in self.skills()]}
517
+
518
+ def serialize(self, value):
519
+ from spatialagents import serialize
520
+
521
+ return serialize.clean(value)
522
+
523
+ def split_images(self, result) -> tuple:
524
+ from spatialagents.backend import split_images
525
+
526
+ return split_images(result)
527
+
528
+ def invoke(self, tool: str, arguments: dict, *, policy, approved: bool = False) -> dict:
529
+ from spatialagents import backend
530
+
531
+ arguments = dict(arguments or {})
532
+ with self._lock:
533
+ self.calls.append((tool, arguments, approved))
534
+ spec = self.tools.get(tool)
535
+ if spec is None:
536
+ return backend.failed("unknown_capability", f"no tool {tool!r}")
537
+ problem = self._check(spec, arguments)
538
+ if problem:
539
+ return backend.failed("invalid_input", problem)
540
+ permission = spec.permission_for(arguments)
541
+ refused = self._refused(spec, permission, policy, approved)
542
+ if refused:
543
+ return refused
544
+ operation_id = None
545
+ if permission != "read":
546
+ with self._lock:
547
+ self._ops += 1
548
+ operation_id = f"op_{self._ops:04d}"
549
+ try:
550
+ result = spec.handler(arguments)
551
+ except ToolFailure as exc:
552
+ return backend.failed(exc.code, exc.message, detail=exc.detail, operation_id=operation_id)
553
+ except Exception as exc: # noqa: BLE001 -- the outcome says what happened
554
+ return backend.failed("internal_error", f"{type(exc).__name__}: {exc}", operation_id=operation_id)
555
+ receipt = result.pop("_receipt", None) if isinstance(result, dict) else None
556
+ if permission != "read":
557
+ with self._lock:
558
+ self.revision += 1
559
+ receipt = receipt or {}
560
+ return backend.ok(result, operation_id=operation_id,
561
+ undo=bool(receipt.get("undo", permission == "reversible_write")),
562
+ advanced=receipt.get("advanced"))
563
+
564
+ # -- checks ------------------------------------------------------------------------
565
+
566
+ @staticmethod
567
+ def _check(spec: FakeTool, arguments: dict) -> str | None:
568
+ schema = spec.input_schema or {}
569
+ missing = [name for name in schema.get("required") or () if name not in arguments]
570
+ if missing:
571
+ return f"missing {', '.join(missing)}"
572
+ for name, prop in (schema.get("properties") or {}).items():
573
+ kind = _TYPES.get((prop or {}).get("type"))
574
+ if name in arguments and kind is not None and arguments[name] is not None:
575
+ value = arguments[name]
576
+ if not isinstance(value, kind) or (kind is int and isinstance(value, bool)):
577
+ return f"{name} must be {prop['type']}"
578
+ return None
579
+
580
+ @staticmethod
581
+ def _refused(spec: FakeTool, permission: str, policy, approved: bool) -> dict | None:
582
+ from spatialagents import backend
583
+
584
+ if spec.egress not in policy.egress:
585
+ return backend.failed("permission_required", f"{spec.name} returns {spec.egress} data")
586
+ if permission != "read" and not policy.allow_writes:
587
+ return backend.failed("permission_required", f"{spec.name} changes state, and this conversation "
588
+ "may only read")
589
+ if permission == "source_file_write" and not (policy.allow_source_writes and approved):
590
+ return backend.failed("permission_required", f"{spec.name} writes into the user's source file; "
591
+ "the user must approve it", detail={"permission": permission})
592
+ if permission == "destructive" and not (policy.allow_destructive and approved):
593
+ return backend.failed("permission_required", f"{spec.name} cannot be undone; the user must "
594
+ "approve it", detail={"permission": permission})
595
+ return None
596
+
597
+
598
+ # -- a stand-in plan host --------------------------------------------------------------------------
599
+
600
+ #: The sentence kinds a summary may use, as SCIMAP Pro's claims checker names them.
601
+ SENTENCE_KINDS = ("computed", "interpretation", "background", "proposed")
602
+
603
+
604
+ class FakePlanHost:
605
+ """A `plans.PlanHost` over functions a test defines: the reference host.
606
+
607
+ `functions` maps a name to `handler(arguments) -> result` (raise to fail
608
+ the step). A composed answer is `{"steps": [{"id", "function",
609
+ "arguments", "rationale"}], "summary"}`; a step naming a function there is
610
+ not is a repairable `unknown_function` problem, and no steps is "nothing
611
+ to run". `decisions` are added to every composed plan (blocking, each
612
+ `{id, kind, question, options?, binds?}`); an answer that `binds` a name
613
+ fills `{name}` in the steps' arguments. A step with `checkpoint: true`
614
+ raises a non-blocking `confirm_labels` decision after it runs, and the run
615
+ waits there. A summary's computed sentence must cite a step that ran;
616
+ settling downgrades one that does not to an interpretation, and writes
617
+ `<root>/<plan_id>.report.md` when a root is given."""
618
+
619
+ def __init__(self, functions: dict, *, decisions=(), root=None, compose_task: str = "plan.compose",
620
+ summarise_task: str = "plan.summarise", claim_ceiling: str = "descriptive"):
621
+ self.functions = dict(functions)
622
+ self.decision_specs = [dict(d) for d in decisions]
623
+ self.root = root
624
+ self.compose_task = compose_task
625
+ self.summarise_task = summarise_task
626
+ self.claim_ceiling = claim_ceiling
627
+ self.plans: dict = {}
628
+ self.calls: list = []
629
+ self._n = 0
630
+ self._lock = threading.Lock()
631
+
632
+ # -- compose -----------------------------------------------------------------
633
+
634
+ def compose_schema(self) -> dict:
635
+ value = {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}, {"type": "null"}]}
636
+ return {"type": "object", "properties": {
637
+ "steps": {"type": "array", "items": {"type": "object", "properties": {
638
+ "id": {"type": "string"}, "function": {"type": "string", "enum": sorted(self.functions)},
639
+ "arguments": {"type": "object", "additionalProperties": value},
640
+ "rationale": {"type": "string"}, "checkpoint": {"type": "boolean"}},
641
+ "required": ["id", "function", "arguments", "rationale"]}},
642
+ "summary": {"type": "string"}}, "required": ["steps", "summary"]}
643
+
644
+ def compose_packet(self, goal: str, dataset: str) -> dict:
645
+ self.calls.append(("compose_packet", goal, dataset))
646
+ return {"task": self.compose_task, "schema": self.compose_schema(),
647
+ "input": {"goal": goal, "dataset": dataset, "functions": sorted(self.functions),
648
+ "instructions": "Compose steps from `functions` that answer the goal."}}
649
+
650
+ def accept_composed(self, goal: str, dataset: str, answer: dict, *, plan_id: str | None = None):
651
+ self.calls.append(("accept_composed", plan_id))
652
+ steps = list((answer or {}).get("steps") or [])
653
+ if not steps:
654
+ return None, []
655
+ if plan_id is not None and self.plans.get(plan_id, {}).get("status") not in (None, "draft", "validated"):
656
+ raise ValueError(f"plan {plan_id} is confirmed; a new composition is a new plan")
657
+ with self._lock:
658
+ self._n += 1
659
+ plan_id = plan_id or f"pl_{self._n:04d}"
660
+ plan = {"id": plan_id, "version": 1, "goal": goal, "dataset": dataset, "status": "draft",
661
+ "claim_ceiling": self.claim_ceiling, "unit_of_replication": "image", "n_per_arm": None,
662
+ "parameters": {}, "steps": [], "decisions": [], "validation": {"ok": False, "problems": []}}
663
+ problems = []
664
+ for n, step in enumerate(steps, start=1):
665
+ function = str(step.get("function"))
666
+ plan["steps"].append({"id": str(step.get("id") or f"s{n}"), "n": n, "function": function,
667
+ "arguments": dict(step.get("arguments") or {}),
668
+ "template_arguments": dict(step.get("arguments") or {}),
669
+ "rationale": step.get("rationale"), "rationale_by": "agent",
670
+ "checkpoint": bool(step.get("checkpoint")), "status": "pending", "error": None})
671
+ if function not in self.functions:
672
+ problems.append({"step": plan["steps"][-1]["id"], "kind": "unknown_function", "blocking": True,
673
+ "message": f"{function!r} is not a function; choose from {sorted(self.functions)}"})
674
+ plan["decisions"] = [{"state": "open", "answer": None, "blocking": True, "options": [], "binds": [],
675
+ "step_ids": [], **spec} for spec in self.decision_specs]
676
+ plan["validation"]["problems"] = problems
677
+ self.plans[plan_id] = plan
678
+ self._validate(plan)
679
+ return self.plan(plan_id), [p for p in problems if p["kind"] == "unknown_function"]
680
+
681
+ def _validate(self, plan: dict) -> None:
682
+ if plan["status"] not in ("draft", "validated"):
683
+ return
684
+ blocking = [d for d in plan["decisions"] if d["state"] == "open" and d.get("blocking", True)]
685
+ problems = [p for p in plan["validation"]["problems"] if p.get("blocking")]
686
+ plan["validation"]["ok"] = not blocking and not problems
687
+ plan["status"] = "validated" if plan["validation"]["ok"] else "draft"
688
+
689
+ # -- the view and the person ----------------------------------------------------
690
+
691
+ def plan(self, plan_id: str) -> dict:
692
+ if plan_id not in self.plans:
693
+ raise KeyError(f"no plan {plan_id!r}")
694
+ out = json.loads(json.dumps(self.plans[plan_id], default=str))
695
+ for step in out["steps"]:
696
+ step.pop("template_arguments", None)
697
+ out["next"] = {"draft": "answer the open decisions", "validated": "confirm to run",
698
+ "awaiting_decision": "answer, then continue", "done": "summarise"}.get(out["status"], "")
699
+ return out
700
+
701
+ def answer(self, plan_id: str, decision_id: str, value) -> dict:
702
+ self.calls.append(("answer", decision_id, value))
703
+ plan = self.plans[plan_id]
704
+ decision = next((d for d in plan["decisions"] if d["id"] == decision_id), None)
705
+ if decision is None:
706
+ raise ValueError(f"plan {plan_id} has no decision {decision_id!r}")
707
+ ids = [o.get("id") for o in decision.get("options") or () if isinstance(o, dict)]
708
+ if ids and value not in ids:
709
+ raise ValueError(f"{value!r} is not one of {ids}")
710
+ decision.update(state="answered", answer=value, answered_by="user")
711
+ for name in decision.get("binds") or ():
712
+ plan["parameters"][name] = value
713
+ bound = plan["parameters"]
714
+ for step in plan["steps"]:
715
+ step["arguments"] = {k: bound.get(v[1:-1], v) if isinstance(v, str) and v[:1] == "{" and v[-1:] == "}"
716
+ else v for k, v in step["template_arguments"].items()}
717
+ self._validate(plan)
718
+ return self.plan(plan_id)
719
+
720
+ # -- the run ---------------------------------------------------------------------------
721
+
722
+ def run(self, plan_id: str, *, confirm: bool = False, from_step=None, until_step=None) -> dict:
723
+ self.calls.append(("run", confirm, from_step, until_step))
724
+ plan = self.plans[plan_id]
725
+ if plan["status"] in ("draft", "validated"):
726
+ if not confirm:
727
+ return {**self.plan(plan_id), "ran": [], "status": "needs_confirmation"}
728
+ if plan["status"] != "validated":
729
+ raise ValueError(f"plan {plan_id} is not ready to confirm")
730
+ plan["status"] = "confirmed"
731
+ if any(d["state"] == "answered" and d["answer"] == "stop" for d in plan["decisions"]):
732
+ plan["status"] = "stopped"
733
+ return {**self.plan(plan_id), "ran": []}
734
+ ran = []
735
+ plan["status"] = "running"
736
+ started = from_step is None
737
+ for step in plan["steps"]:
738
+ if not started:
739
+ started = step["id"] == str(from_step)
740
+ if not started:
741
+ continue
742
+ if step["status"] == "done" and from_step is None:
743
+ continue
744
+ if any(d["state"] == "open" and step["id"] in (d.get("step_ids") or ()) for d in plan["decisions"]):
745
+ plan["status"] = "awaiting_decision"
746
+ break
747
+ try:
748
+ result = self.functions[step["function"]](dict(step["arguments"]))
749
+ step.update(status="done", error=None, result=result)
750
+ except Exception as exc: # noqa: BLE001 -- recorded on the step
751
+ step.update(status="failed", error={"message": f"{type(exc).__name__}: {exc}"})
752
+ ran.append({"step": step["id"], "n": step["n"], "function": step["function"],
753
+ "status": step["status"], **({"error": step["error"]} if step["error"] else {})})
754
+ if step["status"] == "failed":
755
+ plan["status"] = "failed"
756
+ break
757
+ if step.get("checkpoint"):
758
+ later = [s["id"] for s in plan["steps"] if s["n"] > step["n"]]
759
+ if later and not any(d["id"] == f"d-look-{step['id']}" for d in plan["decisions"]):
760
+ plan["decisions"].append({
761
+ "id": f"d-look-{step['id']}", "kind": "confirm_labels", "blocking": False,
762
+ "question": f"Do the labels {step['id']} made look right?", "state": "open",
763
+ "answer": None, "step_ids": later[:1], "binds": [],
764
+ "options": [{"id": "continue", "label": "They look right; continue", "recommended": True},
765
+ {"id": "stop", "label": "Stop here"}]})
766
+ plan["status"] = "awaiting_decision"
767
+ break
768
+ if until_step is not None and step["id"] == str(until_step):
769
+ plan["status"] = "stopped"
770
+ break
771
+ else:
772
+ plan["status"] = "done"
773
+ return {**self.plan(plan_id), "ran": ran}
774
+
775
+ # -- the summary --------------------------------------------------------------------------
776
+
777
+ def _ran(self, plan: dict) -> dict:
778
+ return {s["id"]: s for s in plan["steps"] if s["status"] == "done"}
779
+
780
+ def summarise_packet(self, plan_id: str) -> dict:
781
+ plan = self.plans[plan_id]
782
+ ran = self._ran(plan)
783
+ cite = {"type": "string", "enum": sorted(ran)} if ran else {"type": "string"}
784
+ return {"task": self.summarise_task,
785
+ "input": {"goal": plan["goal"], "claim_ceiling": plan["claim_ceiling"],
786
+ "results": [{"cite": sid, "function": s["function"], "result": s.get("result")}
787
+ for sid, s in ran.items()]},
788
+ "schema": {"type": "object", "properties": {"sentences": {"type": "array", "items": {
789
+ "type": "object", "properties": {"kind": {"type": "string", "enum": list(SENTENCE_KINDS)},
790
+ "text": {"type": "string"},
791
+ "cites": {"type": "array", "items": cite}},
792
+ "required": ["kind", "text", "cites"]}}}, "required": ["sentences"]}}
793
+
794
+ def accept_summary(self, plan_id: str, answer: dict) -> list:
795
+ ran = self._ran(self.plans[plan_id])
796
+ problems = []
797
+ for n, sentence in enumerate((answer or {}).get("sentences") or (), start=1):
798
+ if sentence.get("kind") == "computed" and not sentence.get("cites"):
799
+ problems.append({"sentence": n, "message": f"sentence {n} is computed but cites no result"})
800
+ unknown = [c for c in sentence.get("cites") or () if c not in ran]
801
+ if unknown:
802
+ problems.append({"sentence": n, "message": f"sentence {n} cites {unknown}, which did not run"})
803
+ return problems
804
+
805
+ def settle_summary(self, plan_id: str, answer: dict) -> dict:
806
+ ran = self._ran(self.plans[plan_id])
807
+ settled = []
808
+ for sentence in (answer or {}).get("sentences") or ():
809
+ cites = [c for c in sentence.get("cites") or () if c in ran]
810
+ kind = sentence.get("kind") if sentence.get("kind") in SENTENCE_KINDS else "interpretation"
811
+ caveats = []
812
+ if kind == "computed" and not cites:
813
+ kind, caveats = "interpretation", ["cites no result that ran"]
814
+ settled.append({"kind": kind, "text": str(sentence.get("text") or ""), "cites": cites,
815
+ "caveats": caveats})
816
+ lines = [f"# {self.plans[plan_id]['goal']}", ""]
817
+ for kind in SENTENCE_KINDS:
818
+ mine = [s for s in settled if s["kind"] == kind]
819
+ if mine:
820
+ lines += [f"## {kind.title()}", ""] + [f"- {s['text']}" + (f" [{', '.join(s['cites'])}]"
821
+ if s["cites"] else "")
822
+ for s in mine] + [""]
823
+ markdown = "\n".join(lines)
824
+ path = None
825
+ if self.root is not None:
826
+ from pathlib import Path
827
+
828
+ path = Path(self.root) / f"{plan_id}.report.md"
829
+ path.parent.mkdir(parents=True, exist_ok=True)
830
+ path.write_text(markdown, encoding="utf-8")
831
+ return {"sentences": settled, "markdown": markdown, "path": str(path) if path else None}
832
+
833
+
834
+ def plan_brain(compose, summary=None, *, subagent=None):
835
+ """A FakeGateway brain for plan packets: `compose` answers (a list, used in
836
+ order, or one answer) for compose calls and `summary` likewise for summary
837
+ calls, told apart by the task; anything else answers `{}`."""
838
+ queues = {"compose": list(compose) if isinstance(compose, list) else [compose],
839
+ "summarise": list(summary) if isinstance(summary, list) else ([summary] if summary else [])}
840
+
841
+ def brain(packet, body):
842
+ task = body.get("task") or ""
843
+ name = "compose" if task.endswith(".compose") else "summarise" if task.endswith(".summarise") else None
844
+ if name is None or not queues[name]:
845
+ return {}
846
+ step = queues[name].pop(0) if len(queues[name]) > 1 else queues[name][0]
847
+ return step(body) if callable(step) else step
848
+ return brain
849
+
850
+
851
+ # -- a scripted model ----------------------------------------------------------------------------
852
+
853
+
854
+ class ScriptedBrain:
855
+ """A FakeGateway brain that answers from a script.
856
+
857
+ `steps` answer the main conversation's calls in order: a `Reply`, a string
858
+ or JSON-able answer, `(status, code)` to refuse, or a function of the
859
+ request body returning one of those. When the script runs out it answers
860
+ `final`. `subagent` (a function of the body, or a list like `steps`)
861
+ answers sub-agent calls (`context.agent == "chat_subagent"`). `bodies`
862
+ keeps every request body it was asked with.
863
+
864
+ `ScriptedBrain.replaying(calls)` is the oracle form: each call of a plan
865
+ (`{"name", "arguments"}`) is issued through `call_tool`, one per model
866
+ call, then `final`."""
867
+
868
+ def __init__(self, steps=(), *, final="done.", subagent=None):
869
+ self.steps = list(steps)
870
+ self.final = final
871
+ self.subagent = subagent if callable(subagent) or subagent is None else list(subagent)
872
+ self.bodies: list[dict] = []
873
+ self._lock = threading.Lock()
874
+
875
+ @classmethod
876
+ def replaying(cls, calls, *, final: str = "done.") -> "ScriptedBrain":
877
+ steps = [Reply("", [{"name": "call_tool", "input": {"name": c["name"],
878
+ "arguments": dict(c.get("arguments") or {})}}])
879
+ for c in calls]
880
+ return cls(steps, final=final)
881
+
882
+ def _next(self, queue: list, body):
883
+ step = queue.pop(0) if queue else Reply(self.final)
884
+ return step(body) if callable(step) else step
885
+
886
+ def __call__(self, packet, body):
887
+ with self._lock:
888
+ self.bodies.append(body)
889
+ if (body.get("context") or {}).get("agent") == "chat_subagent" and self.subagent is not None:
890
+ if callable(self.subagent):
891
+ return self.subagent(body)
892
+ return self._next(self.subagent, body)
893
+ return self._next(self.steps, body)
894
+
895
+
896
+ # -- pytest fixtures (inert unless asked for) -------------------------------------------------------
897
+
898
+ try:
899
+ import pytest
900
+ except ImportError: # pragma: no cover - the package works without pytest
901
+ pytest = None
902
+
903
+ if pytest is not None:
904
+
905
+ @pytest.fixture
906
+ def spatialagents_gateway():
907
+ """`start(brain, **options)` -> a running FakeGateway, stopped at teardown."""
908
+ started = []
909
+
910
+ def start(brain, **options):
911
+ fake = FakeGateway(brain, **options).__enter__()
912
+ started.append(fake)
913
+ return fake
914
+
915
+ yield start
916
+ for fake in reversed(started):
917
+ fake.__exit__(None, None, None)
918
+
919
+ @pytest.fixture
920
+ def spatialagents_tokens():
921
+ """A token source for product `testapp` that needs no licence."""
922
+ return fixed_tokens()
923
+
924
+ @pytest.fixture
925
+ def spatialagents_backend():
926
+ """An empty FakeToolBackend; add tools with `.add(FakeTool(...))` or `@backend.tool(...)`."""
927
+ return FakeToolBackend()