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
|
@@ -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
|
+
|
spatialagents/errors.py
ADDED
|
@@ -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}
|
spatialagents/gateway.py
ADDED
|
@@ -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"))
|
spatialagents/offload.py
ADDED
|
@@ -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)}
|