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.
- spatialagents/__init__.py +66 -0
- spatialagents/approvals.py +137 -0
- spatialagents/backend.py +181 -0
- spatialagents/cache_plan.py +117 -0
- spatialagents/chat/__init__.py +11 -0
- spatialagents/chat/cli.py +84 -0
- spatialagents/chat/http.py +308 -0
- spatialagents/chat/server.py +269 -0
- spatialagents/chat/static/chat.css +219 -0
- spatialagents/chat/static/chat.js +933 -0
- spatialagents/chat/static/index.html +16 -0
- spatialagents/conversations.py +292 -0
- spatialagents/errors.py +73 -0
- spatialagents/gateway.py +192 -0
- spatialagents/offload.py +92 -0
- spatialagents/orchestrator.py +259 -0
- spatialagents/plans.py +800 -0
- spatialagents/policy.py +118 -0
- spatialagents/prefix.py +79 -0
- spatialagents/py.typed +0 -0
- spatialagents/runner.py +813 -0
- spatialagents/schema.py +167 -0
- spatialagents/serialize.py +88 -0
- spatialagents/sessions.py +276 -0
- spatialagents/testing.py +927 -0
- spatialagents/toolcache.py +144 -0
- spatialagents/tools.py +460 -0
- spatialagents/trace.py +237 -0
- spatialagents/wire.py +194 -0
- spatialagents-0.1.0.dist-info/METADATA +55 -0
- spatialagents-0.1.0.dist-info/RECORD +33 -0
- spatialagents-0.1.0.dist-info/WHEEL +4 -0
- spatialagents-0.1.0.dist-info/entry_points.txt +2 -0
spatialagents/testing.py
ADDED
|
@@ -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()
|