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,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>{{TITLE}}</title>
8
+ <link rel="stylesheet" href="static/chat.css">
9
+ <!-- The page's settings: data, never run (the server's CSP allows no inline script). -->
10
+ <script type="application/json" id="spatialagents-settings">{{SETTINGS}}</script>
11
+ </head>
12
+ <body>
13
+ <main id="spatialagents-chat" class="sa-page"></main>
14
+ <script src="static/chat.js" data-global="SpatialAgentsChat"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,292 @@
1
+ """Conversations on disk, and the service that runs their turns in the background.
2
+
3
+ <root>/<conversation_id>/
4
+ session.json the record: frozen catalog and prefix, loaded tools,
5
+ totals, board (SessionStore's atomic writes)
6
+ messages.json the model-facing history (what the next call sends)
7
+ decisions.jsonl the transcript: every event, numbered (`seq`)
8
+ control.json pause / stop / approvals, written by the person's routes
9
+ offload/ large results, unless the product shares one root
10
+ .lock the process driving it
11
+
12
+ The root is the product's (Plexora's `<data_root>/.agent/ai/conversations/`,
13
+ SCIMAP Pro's `<configDir>/ai-sessions/<id>/conversation/`).
14
+
15
+ `ConversationStore` is a `SessionStore` of kind `chat` rooted there, plus the
16
+ messages and the numbered transcript. `ChatService` is the process's one
17
+ driver: `send` starts a turn on a thread and returns at once; `events(after,
18
+ wait_s)` is a held poll over the transcript (what an SSE route and a poll
19
+ fallback both read); `control` and `approve` write `control.json`, which the
20
+ running turn reads before its next call. The service is given what a product
21
+ binds -- a gateway factory, a backend factory, the profile, the cache and the
22
+ trace -- and the product's surfaces (Plexora's `ai.chat_*` capabilities and
23
+ Flask routes, a terminal loop) call it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import threading
30
+ import time
31
+ from pathlib import Path
32
+
33
+ from spatialagents.errors import AgentError
34
+ from spatialagents.sessions import SessionStore, atomic_write
35
+
36
+ #: Events of a turn that are not kept in the transcript once the turn is over:
37
+ #: the deltas are replaced by the turn's `text` events.
38
+ TRANSIENT = ("text_delta",)
39
+ MAX_WAIT_S = 25.0
40
+
41
+
42
+ class ConversationStore(SessionStore):
43
+ def __init__(self, root: Path | str):
44
+ super().__init__("chat", Path(root))
45
+ self._seq: dict = {}
46
+ self._live: dict = {}
47
+ self._cond = threading.Condition()
48
+
49
+ # -- messages ---------------------------------------------------------------
50
+
51
+ def save_messages(self, conversation_id: str, messages: list) -> None:
52
+ atomic_write(self.folder(conversation_id) / "messages.json",
53
+ json.dumps(messages, ensure_ascii=False, separators=(",", ":")))
54
+
55
+ def load_messages(self, conversation_id: str) -> list:
56
+ path = self.folder(conversation_id) / "messages.json"
57
+ if not path.is_file():
58
+ return []
59
+ try:
60
+ return json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, ValueError):
62
+ return []
63
+
64
+ # -- the transcript -----------------------------------------------------------
65
+
66
+ def _next_seq(self, conversation_id: str) -> int:
67
+ if conversation_id not in self._seq:
68
+ events = self.decisions(conversation_id)
69
+ self._seq[conversation_id] = max((int(e.get("seq") or 0) for e in events), default=0)
70
+ self._seq[conversation_id] += 1
71
+ return self._seq[conversation_id]
72
+
73
+ def append_event(self, conversation_id: str, event: dict) -> dict:
74
+ with self._cond:
75
+ numbered = {"seq": self._next_seq(conversation_id), "at": time.time(), **event}
76
+ if event.get("event") not in TRANSIENT:
77
+ self.log(conversation_id, numbered)
78
+ if event.get("event") == "turn_finished":
79
+ # The turn's `text` events carry what its deltas spelled.
80
+ self._live.pop(conversation_id, None)
81
+ else:
82
+ self._live.setdefault(conversation_id, []).append(numbered)
83
+ del self._live[conversation_id][:-2000]
84
+ self._cond.notify_all()
85
+ return numbered
86
+
87
+ def events(self, conversation_id: str, after: int = 0, *, wait_s: float = 0.0, still=None) -> list:
88
+ """Events with `seq > after`, oldest first; waits up to `wait_s` for one
89
+ while `still()` (default: always) says it is worth waiting."""
90
+ deadline = time.monotonic() + max(0.0, min(float(wait_s or 0), MAX_WAIT_S))
91
+ with self._cond:
92
+ while True:
93
+ kept = [e for e in self.decisions(conversation_id) if int(e.get("seq") or 0) > after]
94
+ live = [e for e in self._live.get(conversation_id, []) if int(e["seq"]) > after]
95
+ found = sorted(kept + live, key=lambda e: e["seq"])
96
+ remaining = deadline - time.monotonic()
97
+ if found or remaining <= 0 or (still is not None and not still()):
98
+ return found
99
+ self._cond.wait(min(remaining, 1.0))
100
+
101
+ def last_seq(self, conversation_id: str) -> int:
102
+ with self._cond:
103
+ if conversation_id not in self._seq:
104
+ self._next_seq(conversation_id)
105
+ self._seq[conversation_id] -= 1
106
+ return self._seq[conversation_id]
107
+
108
+
109
+ class ChatService:
110
+ """Every conversation this process drives.
111
+
112
+ `gateway_factory()` gives the gateway client a conversation calls through
113
+ (the product's, on its token source); `backend_factory()` the product's
114
+ `ToolBackend`. `profile`, `cache`, `trace` and `offload_root` are handed to
115
+ every runner, and `runner_options` (poll interval, model, step bounds) as
116
+ well. A product subclasses to supply its defaults lazily."""
117
+
118
+ def __init__(self, store: ConversationStore, *, gateway_factory, backend_factory, profile=None,
119
+ cache=None, trace=None, offload_root=None, runner_options: dict | None = None):
120
+ self.store = store
121
+ self.gateway_factory = gateway_factory
122
+ self.backend_factory = backend_factory
123
+ self.profile = profile
124
+ self.cache = cache
125
+ self.trace = trace
126
+ self.offload_root = offload_root
127
+ self.runner_options = dict(runner_options or {})
128
+ self._runners: dict = {}
129
+ self._threads: dict = {}
130
+ self._lock = threading.Lock()
131
+
132
+ # -- what every runner is given (a product overrides these for its defaults) -----------
133
+
134
+ def _cache(self):
135
+ return self.cache
136
+
137
+ def _trace(self):
138
+ return self.trace
139
+
140
+ def _runner_class(self):
141
+ from spatialagents.runner import AgentRunner
142
+
143
+ return AgentRunner
144
+
145
+ def _options(self) -> dict:
146
+ return {"cache": self._cache(), "trace": self._trace(), "profile": self.profile,
147
+ "offload_root": self.offload_root, **self.runner_options}
148
+
149
+ # -- conversations ---------------------------------------------------------------
150
+
151
+ def start(self, policy, *, title: str | None = None, viewer: bool = False) -> dict:
152
+ runner = self._runner_class().create(self.store, gateway=self.gateway_factory(), policy=policy,
153
+ backend=self.backend_factory(), title=title, viewer=viewer,
154
+ **self._options())
155
+ with self._lock:
156
+ self._runners[runner.conversation_id] = runner
157
+ record = runner.record
158
+ self.store.append_event(runner.conversation_id, {"event": "disclosure", "text": record["disclosure"]})
159
+ return self.describe(runner.conversation_id)
160
+
161
+ def runner(self, conversation_id: str, policy=None):
162
+ with self._lock:
163
+ runner = self._runners.get(conversation_id)
164
+ if runner is None:
165
+ if not self.store.exists(conversation_id):
166
+ raise AgentError("invalid_input", f"no conversation {conversation_id!r}",
167
+ detail={"hint": "ai_chat_history lists the conversations"})
168
+ self.store.claim(conversation_id)
169
+ runner = self._runner_class().open(self.store, conversation_id, gateway=self.gateway_factory(),
170
+ backend=self.backend_factory(), **self._options())
171
+ with self._lock:
172
+ runner = self._runners.setdefault(conversation_id, runner)
173
+ if policy is not None:
174
+ runner.policy = policy
175
+ return runner
176
+
177
+ def running(self, conversation_id: str) -> bool:
178
+ with self._lock:
179
+ thread = self._threads.get(conversation_id)
180
+ return bool(thread and thread.is_alive())
181
+
182
+ def send(self, conversation_id: str, text: str, images=(), *, policy=None) -> dict:
183
+ runner = self.runner(conversation_id, policy)
184
+ with self._lock:
185
+ thread = self._threads.get(conversation_id)
186
+ if thread and thread.is_alive():
187
+ raise AgentError("conflict", "this conversation is still answering the last message",
188
+ detail={"hint": "wait for its done event, or stop it"}, retryable=True)
189
+ after = self.store.last_seq(conversation_id)
190
+ self.store.append_event(conversation_id, {"event": "user_message", "text": text,
191
+ "images": len(images or ())})
192
+
193
+ def work():
194
+ try:
195
+ runner.run_turn(text, images, lambda e: self.store.append_event(conversation_id, e))
196
+ except Exception as exc: # noqa: BLE001 -- the transcript says why
197
+ self.store.append_event(conversation_id, {"event": "error", "code": "internal_error",
198
+ "message": f"{type(exc).__name__}: {exc}"})
199
+ finally:
200
+ self.store.append_event(conversation_id, {"event": "turn_finished",
201
+ "state": (runner.record or {}).get("state")})
202
+
203
+ thread = threading.Thread(target=work, name=f"spatialagents-chat-{conversation_id}", daemon=True)
204
+ self._threads[conversation_id] = thread
205
+ thread.start()
206
+ return {"conversation_id": conversation_id, "after": after, "running": True}
207
+
208
+ def note(self, conversation_id: str, text: str) -> None:
209
+ """Something the user did outside the conversation (an Undo chip) that the
210
+ model reads at the start of its next turn."""
211
+ with self.store.lock(conversation_id):
212
+ record = self.store.load(conversation_id)
213
+ record.setdefault("notes", []).append(text)
214
+ self.store.save(record)
215
+ with self._lock:
216
+ runner = self._runners.get(conversation_id)
217
+ if runner is not None and runner.record is not None:
218
+ runner.record.setdefault("notes", [])
219
+ if text not in runner.record["notes"]:
220
+ runner.record["notes"].append(text)
221
+
222
+ def wait(self, conversation_id: str, timeout: float | None = None) -> bool:
223
+ with self._lock:
224
+ thread = self._threads.get(conversation_id)
225
+ if thread is None:
226
+ return True
227
+ thread.join(timeout)
228
+ return not thread.is_alive()
229
+
230
+ def events(self, conversation_id: str, after: int = 0, wait_s: float = 0.0) -> dict:
231
+ if not self.store.exists(conversation_id):
232
+ raise AgentError("invalid_input", f"no conversation {conversation_id!r}")
233
+ found = self.store.events(conversation_id, after, wait_s=wait_s,
234
+ still=lambda: self.running(conversation_id) or wait_s > 0)
235
+ return {"conversation_id": conversation_id, "events": found,
236
+ "after": found[-1]["seq"] if found else after, "running": self.running(conversation_id),
237
+ "control": _public_control(self.store.control(conversation_id))}
238
+
239
+ def control(self, conversation_id: str, action: str) -> dict:
240
+ if not self.store.exists(conversation_id):
241
+ raise AgentError("invalid_input", f"no conversation {conversation_id!r}")
242
+ if action == "pause":
243
+ control = self.store.set_control(conversation_id, paused=True, paused_by="viewer")
244
+ elif action == "resume":
245
+ control = self.store.set_control(conversation_id, paused=False, paused_by=None)
246
+ elif action == "stop":
247
+ from spatialagents.runner import now_iso
248
+
249
+ control = self.store.set_control(conversation_id, stopped=True, stopped_by="viewer",
250
+ stopped_at=now_iso(), paused=False)
251
+ else:
252
+ raise AgentError("invalid_input", f"unknown action {action!r}; pause, resume or stop")
253
+ self.store.append_event(conversation_id, {"event": "control", "action": action})
254
+ return _public_control(control)
255
+
256
+ def approve(self, conversation_id: str, approval_id: str, approve: bool, *, by: str = "viewer") -> dict:
257
+ from spatialagents.approvals import decide
258
+
259
+ if not self.store.exists(conversation_id):
260
+ raise AgentError("invalid_input", f"no conversation {conversation_id!r}")
261
+ return decide(self.store, conversation_id, approval_id, approve, by=by)
262
+
263
+ def describe(self, conversation_id: str) -> dict:
264
+ record = self.store.load(conversation_id)
265
+ return {"conversation_id": conversation_id, "title": record.get("title") or "",
266
+ "state": record.get("state"), "created_at": record.get("created_at"),
267
+ "turns": record.get("turns", 0), "tools": len(record.get("catalog") or []),
268
+ "loaded_tools": record.get("loaded") or [], "prefix_fingerprint": record.get("prefix_fp"),
269
+ "charged_micro": record.get("charged_micro", 0),
270
+ "charged_credits": round(int(record.get("charged_micro") or 0) / 10_000, 2),
271
+ "usage": record.get("usage") or {}, "disclosure": record.get("disclosure"),
272
+ "running": self.running(conversation_id),
273
+ "control": _public_control(self.store.control(conversation_id))}
274
+
275
+ def history(self, conversation_id: str | None = None, *, after: int = 0, limit: int = 500,
276
+ wait_s: float = 0.0) -> dict:
277
+ if conversation_id is None:
278
+ return {"conversations": [
279
+ {k: r.get(k) for k in ("session_id", "title", "state", "created_at", "turns", "charged_micro")}
280
+ for r in self.store.list(limit=limit)]}
281
+ out = self.describe(conversation_id)
282
+ polled = self.events(conversation_id, after, wait_s)
283
+ out.update(events=polled["events"][-limit:], after=polled["after"])
284
+ return out
285
+
286
+
287
+ def _public_control(control: dict) -> dict:
288
+ approvals = control.get("approvals") or {}
289
+ return {"paused": bool(control.get("paused")), "paused_by": control.get("paused_by"),
290
+ "stopped": bool(control.get("stopped")), "reason": control.get("reason"),
291
+ "pending_approvals": [a for a in approvals.values() if a.get("status") == "pending"]}
292
+
@@ -0,0 +1,73 @@
1
+ """The one shape a failure reaches an agent in.
2
+
3
+ An agent cannot read a traceback the way a person reads a dialog, and it should
4
+ not have to: every failure is a `code` it can branch on, a `message` it can
5
+ repeat to the user, a `detail` with whatever makes the failure actionable (the
6
+ requirements a dataset is missing, the revision somebody else wrote, the
7
+ session another process holds), and whether trying again could help.
8
+
9
+ The codes are a closed list, so an agent's instructions (a product's skills)
10
+ can name them and mean something. `CODES` are the ones every product shares
11
+ and this package raises itself; a product adds its own once, at import, with
12
+ `register` (Plexora's `unknown_project`, `viewer_not_available`, ...), and
13
+ `AgentError` refuses any code nobody registered.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from typing import Any
20
+
21
+ #: Every product's codes. Each is a failure an agent can act on.
22
+ CODES = (
23
+ "unknown_capability",
24
+ "invalid_input",
25
+ "precondition_missing",
26
+ "resource_unavailable",
27
+ "permission_required",
28
+ # A paid capability on a licence that does not unlock it. `detail` names
29
+ # the entitlement and the plan that unlocks it. Not retryable: a licence is
30
+ # not something trying again fixes.
31
+ "license_required",
32
+ "conflict",
33
+ "capability_unavailable",
34
+ "too_large",
35
+ "internal_error",
36
+ )
37
+
38
+ _KNOWN: dict[str, None] = dict.fromkeys(CODES)
39
+ _GUARD = threading.Lock()
40
+
41
+
42
+ def register(*codes: str) -> tuple:
43
+ """Add a product's codes to the closed list (idempotent); returns every
44
+ code now known, the shared ones first."""
45
+ with _GUARD:
46
+ for code in codes:
47
+ if not isinstance(code, str) or not code.replace("_", "").isalnum() or code != code.lower():
48
+ raise ValueError(f"an agent error code is lower snake case, not {code!r}")
49
+ _KNOWN.setdefault(code, None)
50
+ return tuple(_KNOWN)
51
+
52
+
53
+ def known() -> tuple:
54
+ """Every code an `AgentError` may carry: the shared ones and every product's."""
55
+ return tuple(_KNOWN)
56
+
57
+
58
+ class AgentError(Exception):
59
+ """A failure with a code an agent can act on."""
60
+
61
+ def __init__(self, code: str, message: str, *, detail: Any = None,
62
+ retryable: bool = False):
63
+ if code not in _KNOWN:
64
+ raise ValueError(f"unknown agent error code {code!r}")
65
+ super().__init__(message)
66
+ self.code = code
67
+ self.message = message
68
+ self.detail = detail
69
+ self.retryable = retryable
70
+
71
+ def to_problem(self) -> dict:
72
+ return {"code": self.code, "message": self.message,
73
+ "detail": self.detail, "retryable": self.retryable}
@@ -0,0 +1,192 @@
1
+ """Talking to the BioCognia AI gateway: the harness's model call.
2
+
3
+ The transport is the shared BioCognia client's (`biocognia.gateway`): the
4
+ 30-minute `BIOCAI1` token obtained against the device's certificate, the
5
+ gateway URL, one token refresh, server-sent event parsing, and the account
6
+ endpoints (balance, usage, pricing, runs). The product injects its token
7
+ source -- `biocognia.gateway.TokenSource(<its LICENSING>)` -- and nothing here
8
+ ever reads a licence or writes a token source of its own. What is here is
9
+ what belongs to the harness: `messages()` -- one streamed call, its retries,
10
+ and reading Anthropic-shaped events between `biocognia.accepted` and
11
+ `biocognia.usage` into a `ModelResponse` -- and naming the call's task on the
12
+ wire as `<product>.<module>.<task>` (`wire_task`), the product being the one
13
+ the token source's licence is for.
14
+
15
+ `BIOCOGNIA_AI_TOKEN` supplies a token directly (a CI job, a test);
16
+ `BIOCOGNIA_AI_GATEWAY` overrides the gateway's address; `BIOCOGNIA_AI_DEV=1`
17
+ (or `dev=True`) uses the dev route, which only internal testing accounts may
18
+ call and which bills at provider cost. `BIOCOGNIA_OFFLINE=1` refuses before
19
+ any socket opens.
20
+
21
+ Retries: a refusal that happened before anything reached a provider (rate
22
+ limit, provider outage, unreachable gateway) is retried with the SAME
23
+ idempotency key, which the gateway frees in exactly those cases; an expired
24
+ token is refreshed once. Credit, cap and envelope refusals are never retried:
25
+ they are surfaced so the run or conversation can pause (`PAUSE_CODES`).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import time
32
+
33
+ from biocognia import codes as _codes
34
+ from biocognia import gateway as _shared
35
+ from biocognia.gateway import (ENV_DEV, ENV_GATEWAY, ENV_TOKEN, RETRYABLE, GatewayError, TokenSource, dev_default,
36
+ gateway_url, iter_sse)
37
+
38
+ from spatialagents import wire
39
+ from spatialagents.wire import ModelRequest, ModelResponse, Usage
40
+
41
+ #: Tries in all for a provider whose circuit is open: it is down for a known
42
+ #: while (`retry_after`), so waiting that out once is worth it and six blind
43
+ #: tries are not -- the run pauses instead (PAUSE_CODES). The gateway says so
44
+ #: as `provider_unavailable` with `details.failure: circuit_open`.
45
+ CIRCUIT_ATTEMPTS = 2
46
+ CALL_TIMEOUT = 300
47
+
48
+ #: Refusals that pause the run or conversation for the person instead of
49
+ #: failing it: the platform's (`biocognia.codes.PAUSE_CODES` -- credits, the
50
+ #: organisation's, member's and product's monthly caps, the run's envelope, AI
51
+ #: switched off or not licensed), and the harness's own -- a provider still
52
+ #: down after its retries, a closed run, the dev route refused, no licence on
53
+ #: this device, offline.
54
+ PAUSE_CODES = (*_codes.PAUSE_CODES, "run_closed", "provider_unavailable", "circuit_open",
55
+ "provider_rate_limited", "dev_not_allowed", "no_license", "offline_refused")
56
+ #: The pauses that money or a raised cap answers ("add credits, then resume").
57
+ CREDIT_CODES = _codes.CREDIT_CODES
58
+
59
+ __all__ = ["CALL_TIMEOUT", "CIRCUIT_ATTEMPTS", "CREDIT_CODES", "ENV_DEV", "ENV_GATEWAY", "ENV_TOKEN",
60
+ "PAUSE_CODES", "RETRYABLE", "GatewayClient", "GatewayError", "TokenSource", "dev_default",
61
+ "gateway_url", "iter_sse"]
62
+
63
+
64
+ class GatewayClient(_shared.GatewayClient):
65
+ """The shared transport, plus the harness's streamed model call.
66
+
67
+ `tokens` is the product's `biocognia.gateway.TokenSource`; its licence's
68
+ product names every task this client sends, unless `product` says
69
+ otherwise."""
70
+
71
+ def __init__(self, tokens: TokenSource, base_url: str | None = None, *, dev: bool | None = None,
72
+ max_attempts: int = 6, sleep=time.sleep, product: str | None = None):
73
+ super().__init__(tokens, base_url or None, dev=dev)
74
+ self.max_attempts = max_attempts
75
+ self._sleep = sleep
76
+ self._product = product
77
+
78
+ @property
79
+ def product(self) -> str:
80
+ """The product key every task is filed under on the gateway (the certificate's `aud`)."""
81
+ return self._product or self.tokens.licensing.product.id
82
+
83
+ def wire_task(self, task: str | None, *, module: str | None = None) -> str:
84
+ """`request.task` as the gateway knows it: `<product>.<module>.<task>`
85
+ (`wire.task_id`). A product with its own task registry overrides this
86
+ to use it."""
87
+ return wire.task_id(self.product, task, module=module)
88
+
89
+ # -- calls ---------------------------------------------------------------------
90
+
91
+ def messages(self, request: ModelRequest, *, idempotency_key: str, on_delta=None,
92
+ on_retry=None) -> ModelResponse:
93
+ """One streamed call. `on_delta(text)` is called with each text delta as
94
+ it arrives (a retried call may repeat the deltas of a cut stream);
95
+ `on_retry(attempt, error)` before each retry. The response's
96
+ `attempts` says how many tries it took."""
97
+ path = "/v1/ai/dev/messages" if self.dev else "/v1/ai/messages"
98
+ module = (request.context or {}).get("feature")
99
+ body = request.envelope(self.wire_task(request.task, module=module))
100
+ if not self.dev:
101
+ body.pop("model", None)
102
+ delay = 2.0
103
+ for attempt in range(1, self.max_attempts + 1):
104
+ started = time.monotonic()
105
+ try:
106
+ with self.open("POST", path, body, {"Idempotency-Key": idempotency_key},
107
+ timeout=CALL_TIMEOUT) as response:
108
+ result = self._read_stream(response, on_delta)
109
+ result.latency_ms = int((time.monotonic() - started) * 1000)
110
+ result.attempts = attempt
111
+ return result
112
+ except GatewayError as exc:
113
+ limit = min(self.max_attempts, CIRCUIT_ATTEMPTS) if exc.circuit_open else self.max_attempts
114
+ if not exc.retryable or attempt >= limit:
115
+ raise
116
+ if on_retry is not None:
117
+ on_retry(attempt, exc)
118
+ self._sleep(min(exc.retry_after or delay, 120.0))
119
+ delay = min(delay * 3, 120.0)
120
+ raise AssertionError("unreachable")
121
+
122
+ def _read_stream(self, response, on_delta=None) -> ModelResponse:
123
+ text: list[str] = []
124
+ blocks: dict[int, dict] = {}
125
+ partial: dict[int, list[str]] = {}
126
+ usage_event: dict = {}
127
+ accepted: dict = {}
128
+ stop = None
129
+ for event, data in iter_sse(response):
130
+ try:
131
+ payload = json.loads(data)
132
+ except ValueError:
133
+ continue
134
+ if event == "biocognia.accepted":
135
+ accepted = payload
136
+ elif event == "biocognia.usage":
137
+ usage_event = payload
138
+ elif event == "biocognia.error":
139
+ raise GatewayError("The gateway stopped mid-answer.", code=payload.get("code", "interrupted"),
140
+ detail=payload)
141
+ elif event == "content_block_start":
142
+ block = dict(payload.get("content_block") or {})
143
+ index = int(payload.get("index") or 0)
144
+ if block.get("type") == "text":
145
+ blocks[index] = {"type": "text", "text": block.get("text") or ""}
146
+ elif block.get("type") == "tool_use":
147
+ blocks[index] = {"type": "tool_use", "id": block.get("id"), "name": block.get("name"),
148
+ "input": block.get("input") or {}}
149
+ partial[index] = []
150
+ elif event == "content_block_delta":
151
+ delta = payload.get("delta") or {}
152
+ index = int(payload.get("index") or 0)
153
+ if delta.get("type") == "text_delta":
154
+ piece = delta.get("text", "")
155
+ text.append(piece)
156
+ if index in blocks and blocks[index]["type"] == "text":
157
+ blocks[index]["text"] += piece
158
+ elif index not in blocks:
159
+ blocks[index] = {"type": "text", "text": piece}
160
+ if on_delta is not None and piece:
161
+ on_delta(piece)
162
+ elif delta.get("type") == "input_json_delta" and index in partial:
163
+ partial[index].append(delta.get("partial_json") or "")
164
+ elif event == "content_block_stop":
165
+ index = int(payload.get("index") or 0)
166
+ if index in partial:
167
+ raw = "".join(partial.pop(index))
168
+ try:
169
+ blocks[index]["input"] = json.loads(raw) if raw.strip() else blocks[index]["input"]
170
+ except ValueError:
171
+ blocks[index]["input"] = {"_unparsed": raw}
172
+ elif event == "message_delta":
173
+ stop = (payload.get("delta") or {}).get("stop_reason") or stop
174
+ elif event == "error":
175
+ raise GatewayError("The model provider failed mid-answer.", code="provider_unavailable",
176
+ detail=payload)
177
+ if not usage_event:
178
+ raise GatewayError("The answer ended before the gateway settled it.", code="interrupted")
179
+ for index, raw in partial.items(): # a stream without content_block_stop
180
+ try:
181
+ blocks[index]["input"] = json.loads("".join(raw) or "{}")
182
+ except ValueError:
183
+ blocks[index]["input"] = {"_unparsed": "".join(raw)}
184
+ return ModelResponse(
185
+ blocks=[blocks[i] for i in sorted(blocks) if blocks[i]["type"] != "text" or blocks[i]["text"]],
186
+ text="".join(text), stop_reason=stop, usage=Usage.of(usage_event.get("usage")),
187
+ gateway_request_id=usage_event.get("gateway_request_id") or accepted.get("gateway_request_id"),
188
+ status=usage_event.get("status", "ok"), price_micro=int(usage_event.get("price_micro") or 0),
189
+ charged_micro=int(usage_event.get("charged_micro") or 0), cost_micro=usage_event.get("cost_micro"),
190
+ billing=usage_event.get("billing", "credits"), model=usage_event.get("model") or accepted.get("model"),
191
+ provider=usage_event.get("provider") or accepted.get("provider"),
192
+ balance=usage_event.get("balance") or {}, run=usage_event.get("run"))
@@ -0,0 +1,92 @@
1
+ """Offloading large tool results: the model sees a stub, and asks for slices.
2
+
3
+ A tool result above `threshold_tokens` (4k by default) is stored, content-
4
+ addressed, as `<root>/<id>.txt` -- the root is the product's (Plexora shares
5
+ one across conversations, SCIMAP Pro keeps one per session) -- and the model is
6
+ given `{artifact_id, kind, head, tail, size}` instead -- the first and last
7
+ few hundred characters, and how big the whole is. `read_artifact(id, range |
8
+ query)` (a local tool in chat mode) returns a character range or the lines
9
+ that match a query. Context stays small; nothing is lost; and the same result
10
+ offloaded twice is stored once and has the same id.
11
+
12
+ The stored text is JSON indented one space per level, so a query matches a
13
+ line that means something (`"marker": "CD45"`) rather than the whole answer.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import os
20
+ import re
21
+ import threading
22
+ from pathlib import Path
23
+
24
+ from spatialagents import serialize
25
+
26
+ CHARS_PER_TOKEN = 3.5
27
+ ID_PATTERN = re.compile(r"^off_[0-9a-f]{20}$")
28
+ HEAD_CHARS = 1200
29
+ TAIL_CHARS = 600
30
+ #: Characters one `read_artifact` slice returns at most.
31
+ MAX_SLICE = 12_000
32
+ MAX_MATCHES = 80
33
+ #: The largest text stored at all (a bigger answer is cut, and says so).
34
+ MAX_STORED = 8 * 1024 * 1024
35
+
36
+
37
+ def _text(result) -> str:
38
+ return serialize.indented(result)
39
+
40
+
41
+ def tokens(text: str) -> int:
42
+ return int(len(text) / CHARS_PER_TOKEN)
43
+
44
+
45
+ def offload(result, threshold_tokens: int = 4000, *, root: Path | str, kind: str = "tool_result") -> dict | None:
46
+ """The stub for `result` when it is larger than `threshold_tokens`, after
47
+ storing it under `root`; None when it is small enough to send as it is.
48
+ `result` is text or JSON-able data (a backend's `serialize` output)."""
49
+ text = _text(result)
50
+ if tokens(text) <= threshold_tokens:
51
+ return None
52
+ cut = len(text) > MAX_STORED
53
+ if cut:
54
+ text = text[:MAX_STORED]
55
+ artifact_id = "off_" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:20]
56
+ folder = Path(root)
57
+ path = folder / f"{artifact_id}.txt"
58
+ if not path.exists():
59
+ folder.mkdir(parents=True, exist_ok=True)
60
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
61
+ tmp.write_text(text, encoding="utf-8", newline="\n")
62
+ os.replace(tmp, path)
63
+ stub = {"offloaded": True, "artifact_id": artifact_id, "kind": kind, "size": len(text),
64
+ "tokens": tokens(text), "lines": text.count("\n") + 1,
65
+ "head": text[:HEAD_CHARS], "tail": text[-TAIL_CHARS:],
66
+ "hint": "read_artifact with this artifact_id and a character range, or a query, "
67
+ "returns the parts you need"}
68
+ if cut:
69
+ stub["truncated_at"] = MAX_STORED
70
+ return stub
71
+
72
+
73
+ def read_artifact(artifact_id: str, *, root: Path | str, start: int | None = None, end: int | None = None,
74
+ query: str | None = None) -> dict:
75
+ """A slice of an offloaded result: characters [start, end), or the lines
76
+ that contain `query` (case-insensitive) with their line numbers."""
77
+ if not ID_PATTERN.match(str(artifact_id)):
78
+ raise KeyError(f"not an offloaded artifact id: {artifact_id!r}")
79
+ path = Path(root) / f"{artifact_id}.txt"
80
+ if not path.is_file():
81
+ raise KeyError(f"no offloaded artifact {artifact_id!r}")
82
+ text = path.read_text(encoding="utf-8")
83
+ if query:
84
+ needle = str(query).lower()
85
+ matches = [{"line": n + 1, "text": line[:400]}
86
+ for n, line in enumerate(text.splitlines()) if needle in line.lower()]
87
+ return {"artifact_id": artifact_id, "query": query, "matches": matches[:MAX_MATCHES],
88
+ "n_matches": len(matches), "truncated": len(matches) > MAX_MATCHES}
89
+ lo = max(0, int(start or 0))
90
+ hi = min(len(text), int(end) if end is not None else lo + MAX_SLICE, lo + MAX_SLICE)
91
+ return {"artifact_id": artifact_id, "start": lo, "end": hi, "size": len(text),
92
+ "text": text[lo:hi], "more": hi < len(text)}