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/trace.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The local trace: what the harness did, call by call.
|
|
2
|
+
|
|
3
|
+
One SQLite file at a path the product chooses (Plexora's
|
|
4
|
+
`<data_root>/.agent/ai/trace.sqlite`, SCIMAP Pro's
|
|
5
|
+
`<configDir>/ai-cache/trace.sqlite`). Local and complete -- packet ids,
|
|
6
|
+
tokens, cache verdicts, credit, latency -- and linked to the gateway's
|
|
7
|
+
metadata row only by `gateway_request_id`. No prompt, image or answer text is
|
|
8
|
+
written here either; those stay in the session store the engine already keeps.
|
|
9
|
+
Request ids, never headers: the gateway token lives only in the token source.
|
|
10
|
+
|
|
11
|
+
`NullTrace` is the trace of a runner that was given none: it records nothing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
24
|
+
run_id TEXT PRIMARY KEY, kind TEXT NOT NULL, project TEXT, session_id TEXT, gateway_run_id TEXT,
|
|
25
|
+
capability TEXT, billing TEXT, status TEXT NOT NULL, started_at REAL NOT NULL, finished_at REAL,
|
|
26
|
+
summary_json TEXT);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS model_calls (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, worker INTEGER NOT NULL, seq INTEGER NOT NULL,
|
|
29
|
+
packet_id TEXT, kind TEXT, capability TEXT, prefix_fp TEXT, verdict TEXT,
|
|
30
|
+
input_uncached INTEGER, cache_read INTEGER, cache_write INTEGER, output_tokens INTEGER,
|
|
31
|
+
price_micro INTEGER, charged_micro INTEGER, cost_micro INTEGER, gateway_request_id TEXT,
|
|
32
|
+
latency_ms INTEGER, valid INTEGER, at REAL NOT NULL, task TEXT, model TEXT, provider TEXT,
|
|
33
|
+
attempts INTEGER, problem TEXT);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS model_calls_run ON model_calls(run_id, id);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, agent TEXT, tool TEXT NOT NULL,
|
|
37
|
+
capability TEXT, permission TEXT, source TEXT NOT NULL, ok INTEGER, offloaded INTEGER,
|
|
38
|
+
operation_id TEXT, latency_ms INTEGER, at REAL NOT NULL);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS tool_calls_run ON tool_calls(run_id, id);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
41
|
+
run_id TEXT NOT NULL, task_id TEXT NOT NULL, parent TEXT, label TEXT, state TEXT NOT NULL,
|
|
42
|
+
started_at REAL, finished_at REAL, detail_json TEXT, PRIMARY KEY (run_id, task_id));
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: model_calls columns added after the table first shipped: the task and what
|
|
47
|
+
#: served it; the tries the gateway client made, and why an answer was refused.
|
|
48
|
+
LATE_COLUMNS = {"task": "TEXT", "model": "TEXT", "provider": "TEXT", "attempts": "INTEGER", "problem": "TEXT"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TraceStore:
|
|
52
|
+
def __init__(self, path: Path | str):
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
with self._connect() as db:
|
|
57
|
+
db.executescript(SCHEMA)
|
|
58
|
+
# A trace written before these columns: CREATE IF NOT EXISTS never adds them.
|
|
59
|
+
have = {row[1] for row in db.execute("PRAGMA table_info(model_calls)")}
|
|
60
|
+
for column, kind in LATE_COLUMNS.items():
|
|
61
|
+
if column not in have:
|
|
62
|
+
try:
|
|
63
|
+
db.execute(f"ALTER TABLE model_calls ADD COLUMN {column} {kind}")
|
|
64
|
+
except sqlite3.OperationalError:
|
|
65
|
+
pass # another process added it first
|
|
66
|
+
|
|
67
|
+
def _connect(self):
|
|
68
|
+
db = sqlite3.connect(self.path, timeout=30)
|
|
69
|
+
db.row_factory = sqlite3.Row
|
|
70
|
+
return db
|
|
71
|
+
|
|
72
|
+
def _write(self, sql: str, params=()):
|
|
73
|
+
with self._lock, self._connect() as db:
|
|
74
|
+
db.execute(sql, params)
|
|
75
|
+
|
|
76
|
+
def start_run(self, run_id: str, kind: str, **fields) -> None:
|
|
77
|
+
self._write("INSERT OR REPLACE INTO runs (run_id, kind, project, session_id, gateway_run_id, capability, "
|
|
78
|
+
"billing, status, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?)",
|
|
79
|
+
(run_id, kind, fields.get("project"), fields.get("session_id"), fields.get("gateway_run_id"),
|
|
80
|
+
fields.get("capability"), fields.get("billing"), time.time()))
|
|
81
|
+
|
|
82
|
+
def update_run(self, run_id: str, **fields) -> None:
|
|
83
|
+
if "summary" in fields:
|
|
84
|
+
fields["summary_json"] = json.dumps(fields.pop("summary"), default=str)
|
|
85
|
+
if not fields:
|
|
86
|
+
return
|
|
87
|
+
names = ", ".join(f"{name} = ?" for name in fields)
|
|
88
|
+
self._write(f"UPDATE runs SET {names} WHERE run_id = ?", (*fields.values(), run_id))
|
|
89
|
+
|
|
90
|
+
def finish_run(self, run_id: str, status: str, summary: dict) -> None:
|
|
91
|
+
self.update_run(run_id, status=status, finished_at=time.time(), summary=summary)
|
|
92
|
+
|
|
93
|
+
def call(self, run_id: str, **f) -> None:
|
|
94
|
+
self._write(
|
|
95
|
+
"INSERT INTO model_calls (run_id, worker, seq, packet_id, kind, capability, prefix_fp, verdict, "
|
|
96
|
+
"input_uncached, cache_read, cache_write, output_tokens, price_micro, charged_micro, cost_micro, "
|
|
97
|
+
"gateway_request_id, latency_ms, valid, at, task, model, provider, attempts, problem) "
|
|
98
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
99
|
+
(run_id, f.get("worker", 0), f.get("seq", 0), f.get("packet_id"), f.get("kind"), f.get("capability"),
|
|
100
|
+
f.get("prefix_fp"), f.get("verdict"), f.get("input_uncached", 0), f.get("cache_read", 0),
|
|
101
|
+
f.get("cache_write", 0), f.get("output_tokens", 0), f.get("price_micro", 0), f.get("charged_micro", 0),
|
|
102
|
+
f.get("cost_micro"), f.get("gateway_request_id"), f.get("latency_ms", 0),
|
|
103
|
+
None if f.get("valid") is None else int(bool(f.get("valid"))), time.time(), f.get("task"),
|
|
104
|
+
f.get("model"), f.get("provider"), f.get("attempts"), f.get("problem")))
|
|
105
|
+
|
|
106
|
+
def tool_call(self, run_id: str, **f) -> None:
|
|
107
|
+
"""One tool call of a conversation. `source` is `live`, `cache` (the
|
|
108
|
+
tool-result cache answered), `local` (a harness tool) or `declined`."""
|
|
109
|
+
self._write(
|
|
110
|
+
"INSERT INTO tool_calls (run_id, agent, tool, capability, permission, source, ok, offloaded, "
|
|
111
|
+
"operation_id, latency_ms, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
112
|
+
(run_id, f.get("agent"), f.get("tool"), f.get("capability"), f.get("permission"),
|
|
113
|
+
f.get("source", "live"), None if f.get("ok") is None else int(bool(f.get("ok"))),
|
|
114
|
+
int(bool(f.get("offloaded"))), f.get("operation_id"), f.get("latency_ms", 0), time.time()))
|
|
115
|
+
|
|
116
|
+
def tool_calls(self, run_id: str) -> list[dict]:
|
|
117
|
+
with self._connect() as db:
|
|
118
|
+
return [dict(r) for r in db.execute("SELECT * FROM tool_calls WHERE run_id = ? ORDER BY id",
|
|
119
|
+
(run_id,))]
|
|
120
|
+
|
|
121
|
+
def task(self, run_id: str, task_id: str, state: str, **f) -> None:
|
|
122
|
+
now = time.time()
|
|
123
|
+
with self._lock, self._connect() as db:
|
|
124
|
+
db.execute(
|
|
125
|
+
"INSERT INTO tasks (run_id, task_id, parent, label, state, started_at, detail_json) "
|
|
126
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, task_id) DO UPDATE SET state = excluded.state, "
|
|
127
|
+
"finished_at = CASE WHEN excluded.state IN ('done', 'failed', 'cancelled', 'skipped') THEN ? "
|
|
128
|
+
"ELSE finished_at END, detail_json = COALESCE(excluded.detail_json, detail_json)",
|
|
129
|
+
(run_id, task_id, f.get("parent"), f.get("label"), state, now,
|
|
130
|
+
json.dumps(f["detail"], default=str) if f.get("detail") is not None else None, now))
|
|
131
|
+
|
|
132
|
+
# -- reading ---------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def runs(self, limit: int = 20) -> list[dict]:
|
|
135
|
+
with self._connect() as db:
|
|
136
|
+
return [dict(r) for r in db.execute("SELECT * FROM runs ORDER BY started_at DESC LIMIT ?", (limit,))]
|
|
137
|
+
|
|
138
|
+
def run(self, run_id: str) -> dict | None:
|
|
139
|
+
with self._connect() as db:
|
|
140
|
+
row = db.execute("SELECT * FROM runs WHERE run_id = ? OR run_id LIKE ? ORDER BY started_at DESC",
|
|
141
|
+
(run_id, f"{run_id}%")).fetchone()
|
|
142
|
+
return dict(row) if row else None
|
|
143
|
+
|
|
144
|
+
def calls(self, run_id: str) -> list[dict]:
|
|
145
|
+
with self._connect() as db:
|
|
146
|
+
return [dict(r) for r in db.execute("SELECT * FROM model_calls WHERE run_id = ? ORDER BY id", (run_id,))]
|
|
147
|
+
|
|
148
|
+
def tasks(self, run_id: str) -> list[dict]:
|
|
149
|
+
with self._connect() as db:
|
|
150
|
+
return [dict(r) for r in db.execute("SELECT * FROM tasks WHERE run_id = ? ORDER BY started_at",
|
|
151
|
+
(run_id,))]
|
|
152
|
+
|
|
153
|
+
def cache_report(self, run_id: str) -> dict:
|
|
154
|
+
"""A run's cache and cost picture. `cache_read_share` is what the hit
|
|
155
|
+
rate says; `fresh_share` is the rest (uncached input and cache writes),
|
|
156
|
+
which is what costs: a run can read 60% and still spend most of its
|
|
157
|
+
money writing. Cost per packet counts repairs and retries in."""
|
|
158
|
+
calls = self.calls(run_id)
|
|
159
|
+
|
|
160
|
+
def tally(rows):
|
|
161
|
+
read = sum(c["cache_read"] or 0 for c in rows)
|
|
162
|
+
total = sum((c["input_uncached"] or 0) + (c["cache_read"] or 0) + (c["cache_write"] or 0) for c in rows)
|
|
163
|
+
return read, total
|
|
164
|
+
|
|
165
|
+
read, total = tally(calls)
|
|
166
|
+
verdicts: dict = {}
|
|
167
|
+
for c in calls:
|
|
168
|
+
verdicts[c["verdict"]] = verdicts.get(c["verdict"], 0) + 1
|
|
169
|
+
workers = len({c["worker"] for c in calls})
|
|
170
|
+
packets = len({c["packet_id"] for c in calls if c["packet_id"] and c["packet_id"] != "context"})
|
|
171
|
+
charged = sum(c["charged_micro"] or 0 for c in calls)
|
|
172
|
+
by_task: dict = {}
|
|
173
|
+
for task in dict.fromkeys(c.get("task") or "-" for c in calls):
|
|
174
|
+
rows = [c for c in calls if (c.get("task") or "-") == task]
|
|
175
|
+
r, t = tally(rows)
|
|
176
|
+
by_task[task] = {"calls": len(rows), "packets": len({c["packet_id"] for c in rows}),
|
|
177
|
+
"charged_micro": sum(c["charged_micro"] or 0 for c in rows),
|
|
178
|
+
"cache_read_share": round(r / t, 4) if t else 0.0}
|
|
179
|
+
tools = self.tool_calls(run_id)
|
|
180
|
+
return {"calls": len(calls), "workers": workers, "verdicts": verdicts,
|
|
181
|
+
"cache_read_share": round(read / total, 4) if total else 0.0,
|
|
182
|
+
"fresh_share": round((total - read) / total, 4) if total else 0.0,
|
|
183
|
+
"prefixes": sorted({c["prefix_fp"] for c in calls if c["prefix_fp"]}),
|
|
184
|
+
"input_tokens": total, "output_tokens": sum(c["output_tokens"] or 0 for c in calls),
|
|
185
|
+
"charged_micro": charged, "packets": packets,
|
|
186
|
+
"charged_per_packet_micro": round(charged / packets) if packets else 0,
|
|
187
|
+
"invalid_answers": sum(1 for c in calls if c["valid"] == 0),
|
|
188
|
+
"retries": sum(max(0, int(c.get("attempts") or 1) - 1) for c in calls),
|
|
189
|
+
"by_task": by_task,
|
|
190
|
+
"tool_latency": {"calls": len(tools),
|
|
191
|
+
"seconds": round(sum(t["latency_ms"] or 0 for t in tools) / 1000, 1)},
|
|
192
|
+
"tool_calls": self._tool_sources(run_id)}
|
|
193
|
+
|
|
194
|
+
def _tool_sources(self, run_id: str) -> dict:
|
|
195
|
+
out: dict = {}
|
|
196
|
+
for row in self.tool_calls(run_id):
|
|
197
|
+
out[row["source"]] = out.get(row["source"], 0) + 1
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class NullTrace:
|
|
202
|
+
"""A trace that records nothing: what a runner uses when it is given none."""
|
|
203
|
+
|
|
204
|
+
path = None
|
|
205
|
+
|
|
206
|
+
def start_run(self, run_id: str, kind: str, **fields) -> None:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def update_run(self, run_id: str, **fields) -> None:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def finish_run(self, run_id: str, status: str, summary: dict) -> None:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def call(self, run_id: str, **f) -> None:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
def tool_call(self, run_id: str, **f) -> None:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
def task(self, run_id: str, task_id: str, state: str, **f) -> None:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def tool_calls(self, run_id: str) -> list[dict]:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
def runs(self, limit: int = 20) -> list[dict]:
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
def run(self, run_id: str) -> dict | None:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def calls(self, run_id: str) -> list[dict]:
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
def tasks(self, run_id: str) -> list[dict]:
|
|
237
|
+
return []
|
spatialagents/wire.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The harness's side of the gateway wire: the envelope it sends, what comes
|
|
2
|
+
back, the canonical JSON every cached byte is built from, and how a task is
|
|
3
|
+
named on the wire."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
#: A task inside a product: `module.task`, lower snake case (`chat.turn`, `gating.planning`).
|
|
13
|
+
TASK_ID = re.compile(r"^[a-z][a-z0-9_]{0,31}\.[a-z][a-z0-9_]{0,31}$")
|
|
14
|
+
#: A task on the wire: `<product>.<module>.<task>` (the gateway's WIRE_TASK).
|
|
15
|
+
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}$")
|
|
16
|
+
#: A module name, which a call whose task no registry maps is filed under.
|
|
17
|
+
MODULE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
18
|
+
#: What a call whose kind no task maps is sent as, under its module.
|
|
19
|
+
FALLBACK_TASK = "default"
|
|
20
|
+
#: The module a call that names neither a task nor a module is filed under.
|
|
21
|
+
FALLBACK_MODULE = "app"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def task_id(product: str, task: str | None, *, module: str | None = None) -> str:
|
|
25
|
+
"""`chat.turn` as the gateway knows it for `product`: `<product>.chat.turn`.
|
|
26
|
+
|
|
27
|
+
One gateway serves every BioCognia product and routes by the longest
|
|
28
|
+
match (`plexora.gating.*`, `plexora.*`, `*`), so every task on the wire
|
|
29
|
+
carries its product. A task already in wire form for this product is sent
|
|
30
|
+
as it is. None (a packet kind no task maps) is `<product>.<module>.default`;
|
|
31
|
+
the gateway serves an unknown task at the product's default and records
|
|
32
|
+
it, so a product release never waits for a gateway deploy."""
|
|
33
|
+
if task and WIRE_TASK.match(task) and task.startswith(f"{product}."):
|
|
34
|
+
return task
|
|
35
|
+
if task and TASK_ID.match(task):
|
|
36
|
+
return f"{product}.{task}"
|
|
37
|
+
head = module if isinstance(module, str) and MODULE.match(module) else FALLBACK_MODULE
|
|
38
|
+
return f"{product}.{head}.{FALLBACK_TASK}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical(value) -> str:
|
|
42
|
+
"""Sorted keys, no whitespace: the same object always gives the same bytes,
|
|
43
|
+
which is what keeps a prompt-cache prefix warm across workers and sessions."""
|
|
44
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def text_block(text: str, *, cache: bool = False) -> dict:
|
|
48
|
+
block = {"type": "text", "text": text}
|
|
49
|
+
if cache:
|
|
50
|
+
block["cache_control"] = {"type": "ephemeral"}
|
|
51
|
+
return block
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def with_breakpoints(messages: list, *, final: bool = False) -> list:
|
|
55
|
+
"""The conversation as sent: the last block of the newest message, and of
|
|
56
|
+
the newest earlier user message, carry a cache breakpoint.
|
|
57
|
+
|
|
58
|
+
The system prompt carries the first breakpoint (prefix.py); these two make
|
|
59
|
+
each call read everything up to its previous turn from cache and write only
|
|
60
|
+
the new turn. Marking the previous turn as well means a call reads exactly
|
|
61
|
+
what the last one wrote, however many image blocks a packet adds (a
|
|
62
|
+
provider looks back only so far from a breakpoint). Three in all, under
|
|
63
|
+
Anthropic's four. A copy: the stored history is never marked, so its bytes
|
|
64
|
+
-- and a saved chat -- stay as they were.
|
|
65
|
+
|
|
66
|
+
`final`: no call will read past this one (its worker retires after it),
|
|
67
|
+
so the newest message is not marked -- writing it would cost a quarter
|
|
68
|
+
more than sending it uncached, for nothing."""
|
|
69
|
+
out = list(messages)
|
|
70
|
+
marks = [len(out) - 1] if out and not final else []
|
|
71
|
+
earlier = next((i for i in range(len(out) - 2, -1, -1) if out[i].get("role") == "user"), None)
|
|
72
|
+
if earlier is not None:
|
|
73
|
+
marks.append(earlier)
|
|
74
|
+
for i in marks:
|
|
75
|
+
content = out[i].get("content")
|
|
76
|
+
if not isinstance(content, list) or not content:
|
|
77
|
+
continue # a plain string stays as it is: its bytes must not change
|
|
78
|
+
out[i] = {**out[i], "content": content[:-1] + [{**content[-1], "cache_control": {"type": "ephemeral"}}]}
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def image_block(data: bytes, fmt: str = "webp") -> dict:
|
|
83
|
+
media = {"webp": "image/webp", "png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg"}
|
|
84
|
+
return {"type": "image", "source": {"type": "base64", "media_type": media.get(fmt, "image/png"),
|
|
85
|
+
"data": base64.b64encode(data).decode("ascii")}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Usage:
|
|
90
|
+
input_uncached: int = 0
|
|
91
|
+
cache_read: int = 0
|
|
92
|
+
cache_write_5m: int = 0
|
|
93
|
+
cache_write_1h: int = 0
|
|
94
|
+
output_tokens: int = 0
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def of(cls, raw) -> "Usage":
|
|
98
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
99
|
+
return cls(**{name: int(raw.get(name) or 0) for name in cls.__dataclass_fields__})
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def cache_write(self) -> int:
|
|
103
|
+
return self.cache_write_5m + self.cache_write_1h
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def input_total(self) -> int:
|
|
107
|
+
return self.input_uncached + self.cache_read + self.cache_write
|
|
108
|
+
|
|
109
|
+
def bte(self) -> float:
|
|
110
|
+
"""Base-input-token equivalents (reads 0.1x, 5-minute writes 1.25x, 1-hour
|
|
111
|
+
writes 2x, output 5x): one number to compare runs by, whatever the model."""
|
|
112
|
+
return (self.input_uncached + 0.1 * self.cache_read + 1.25 * self.cache_write_5m
|
|
113
|
+
+ 2 * self.cache_write_1h + 5 * self.output_tokens)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ModelRequest:
|
|
118
|
+
#: The capability class the harness sizes the call by (images or not, judgement or routine).
|
|
119
|
+
#: Never sent: the gateway routes by `task` alone.
|
|
120
|
+
capability: str
|
|
121
|
+
system: list
|
|
122
|
+
messages: list
|
|
123
|
+
max_tokens: int = 4096
|
|
124
|
+
output_schema: dict | None = None
|
|
125
|
+
context: dict = field(default_factory=dict)
|
|
126
|
+
model: str | None = None # dev route only
|
|
127
|
+
#: Tool definitions (chat mode); decision mode sends none.
|
|
128
|
+
tools: list | None = None
|
|
129
|
+
#: The task this call does, as the product names it (`chat.turn`, `gating.threshold_evaluation`):
|
|
130
|
+
#: the gateway serves each task with the model an administrator assigned to it. The gateway
|
|
131
|
+
#: client puts it on the wire as `<product>.<module>.<task>` (`GatewayClient.wire_task`); None
|
|
132
|
+
#: sends the module's `default` task (the module is `context["feature"]`), which the gateway
|
|
133
|
+
#: serves at the product's default and records.
|
|
134
|
+
task: str | None = None
|
|
135
|
+
|
|
136
|
+
def envelope(self, task: str | None = None) -> dict:
|
|
137
|
+
"""The body `/v1/ai/messages` takes. `task` is the wire id the client
|
|
138
|
+
resolved for this request (`GatewayClient.wire_task`); without one the
|
|
139
|
+
request's own `task` is sent as it is."""
|
|
140
|
+
request = {"system": self.system, "messages": self.messages, "max_tokens": self.max_tokens}
|
|
141
|
+
if self.tools:
|
|
142
|
+
request["tools"] = self.tools
|
|
143
|
+
if self.output_schema is not None:
|
|
144
|
+
request["output_schema"] = self.output_schema
|
|
145
|
+
body = {"task": task if task is not None else self.task, "context": self.context,
|
|
146
|
+
"request": request}
|
|
147
|
+
if self.model:
|
|
148
|
+
body["model"] = self.model
|
|
149
|
+
return body
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ModelResponse:
|
|
154
|
+
text: str
|
|
155
|
+
stop_reason: str | None
|
|
156
|
+
usage: Usage
|
|
157
|
+
gateway_request_id: str | None = None
|
|
158
|
+
status: str = "ok"
|
|
159
|
+
price_micro: int = 0
|
|
160
|
+
charged_micro: int = 0
|
|
161
|
+
cost_micro: int | None = None
|
|
162
|
+
billing: str = "credits"
|
|
163
|
+
#: The approved model that served the call, and through which provider: the gateway's names, which
|
|
164
|
+
#: the harness never chooses or interprets.
|
|
165
|
+
model: str | None = None
|
|
166
|
+
provider: str | None = None
|
|
167
|
+
balance: dict = field(default_factory=dict)
|
|
168
|
+
run: dict | None = None
|
|
169
|
+
latency_ms: int = 0
|
|
170
|
+
#: Tries the gateway client made for this answer (1: first time).
|
|
171
|
+
attempts: int = 1
|
|
172
|
+
#: The answer's content blocks in order: `text` and `tool_use` (with its
|
|
173
|
+
#: parsed `input`). Thinking blocks are not kept: the gateway accepts only
|
|
174
|
+
#: text, image, tool_use and tool_result blocks back.
|
|
175
|
+
blocks: list = field(default_factory=list)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def tool_uses(self) -> list:
|
|
179
|
+
return [b for b in self.blocks if b.get("type") == "tool_use"]
|
|
180
|
+
|
|
181
|
+
def json(self):
|
|
182
|
+
"""The answer as JSON: the whole text, or the first object in it.
|
|
183
|
+
|
|
184
|
+
Weaker models wrap the object in a code fence or prose, and reasoning
|
|
185
|
+
models put `<think>...</think>` first; both are stripped here and the
|
|
186
|
+
answer is still validated against its model afterwards."""
|
|
187
|
+
text = re.sub(r"<think>.*?</think>", "", self.text, flags=re.S).strip()
|
|
188
|
+
try:
|
|
189
|
+
return json.loads(text)
|
|
190
|
+
except ValueError:
|
|
191
|
+
start, end = text.find("{"), text.rfind("}")
|
|
192
|
+
if start >= 0 and end > start:
|
|
193
|
+
return json.loads(text[start:end + 1])
|
|
194
|
+
raise
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spatialagents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The chat harness runtime SCIMAP Pro, Plexora and other BioCognia products share
|
|
5
|
+
Author-email: Ajit Johnson Nirmal <ajitjohnson.n@gmail.com>
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: biocognia<0.2,>=0.1.1
|
|
12
|
+
Requires-Dist: pydantic>=2.13
|
|
13
|
+
Requires-Dist: pyyaml>=6
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# spatialagents
|
|
17
|
+
|
|
18
|
+
The in-app AI conversation runtime BioCognia's products share (Plexora, SCIMAP
|
|
19
|
+
Pro): the streamed model call through the BioCognia AI gateway, a tool loop
|
|
20
|
+
with deferred tool schemas, approvals for source-file writes and deletes,
|
|
21
|
+
sub-agents, deterministic compaction, a tool-result cache, offloaded results,
|
|
22
|
+
the conversation store, the local trace and a terminal loop.
|
|
23
|
+
|
|
24
|
+
A product binds it with three things and never the other way round:
|
|
25
|
+
|
|
26
|
+
- a `spatialagents.backend.ToolBackend`: what its tools are and how one runs
|
|
27
|
+
(`catalog`, `definition`, `permission(tool, arguments)`, `invoke`, ...);
|
|
28
|
+
- a `spatialagents.runner.ChatProfile`: its prompt content, task names and
|
|
29
|
+
wording;
|
|
30
|
+
- its places on disk and its version, given to each store.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from biocognia.gateway import TokenSource
|
|
34
|
+
from spatialagents.conversations import ChatService, ConversationStore
|
|
35
|
+
from spatialagents.gateway import GatewayClient
|
|
36
|
+
from spatialagents.policy import Policy
|
|
37
|
+
|
|
38
|
+
service = ChatService(ConversationStore(root / "conversations"),
|
|
39
|
+
gateway_factory=lambda: GatewayClient(TokenSource(LICENSING)),
|
|
40
|
+
backend_factory=MyBackend, profile=MY_PROFILE)
|
|
41
|
+
started = service.start(Policy())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Dependencies: the standard library, `biocognia`, `pydantic`, `pyyaml`. No
|
|
45
|
+
`mcp`, `anndata`, Flask or numpy, and nothing from either product;
|
|
46
|
+
`import spatialagents` imports nothing beyond the standard library.
|
|
47
|
+
|
|
48
|
+
Tests need no product, no network and no model: `spatialagents.testing` has
|
|
49
|
+
`FakeGateway` (the gateway's wire and a prompt-cache emulator),
|
|
50
|
+
`FakeToolBackend` and `ScriptedBrain`, and registers inert pytest fixtures.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
UV_PROJECT_ENVIRONMENT=$HOME/.venvs/spatialagents uv sync --all-groups
|
|
54
|
+
BIOCOGNIA_OFFLINE=1 $HOME/.venvs/spatialagents/bin/python -m pytest -q -p no:cacheprovider
|
|
55
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
spatialagents/__init__.py,sha256=Gn7K_P5jBQs4ue2jBBtFv22Oh1Ww9UG3hVCqnWIzTBE,3281
|
|
2
|
+
spatialagents/approvals.py,sha256=b6DfuJwhWpKJLDyVGuLFcwCGdGYQ7mM5NFB7UaWHFag,6815
|
|
3
|
+
spatialagents/backend.py,sha256=ycesmKTGEDx_S112dJizSs6R-36ta9Un9ZrysYvEFNs,8911
|
|
4
|
+
spatialagents/cache_plan.py,sha256=2YeWGe5F-nWvzLyAX6tuEE4azdoatU6nk9NnycmVjiY,5603
|
|
5
|
+
spatialagents/conversations.py,sha256=qSe-SlcQjOZvYtI5MJL8xL_9gWIfcfbNTSRIkqGLhss,14670
|
|
6
|
+
spatialagents/errors.py,sha256=nNPNTJmXWESdd74hROeQIlPgUWchrowslGutLdc2XsM,2669
|
|
7
|
+
spatialagents/gateway.py,sha256=1FLPn4-uizgI6jMeCRP6O97SuOpsRMuoVhf96OfiLS8,10300
|
|
8
|
+
spatialagents/offload.py,sha256=VknYLkAlQO4Z4NwXdVKKNO43ej198m7KsZpLTlHP1EI,4016
|
|
9
|
+
spatialagents/orchestrator.py,sha256=dIIdqWKzHd_pOLvH2LpkypbOqVj80vWIq3GfTU1hEkY,11429
|
|
10
|
+
spatialagents/plans.py,sha256=CWHJfDY2d-pdfqqwldSg_TUtGpIec3m9hRUTob57olY,39330
|
|
11
|
+
spatialagents/policy.py,sha256=bgxHvQbqbV2t4uUfkvhEycak3mp0NUTH95Bihn5SeYU,5543
|
|
12
|
+
spatialagents/prefix.py,sha256=yTpg9464oIJ3jjD_IxT2qztonErwpa9mM_6HbX-9CTo,4176
|
|
13
|
+
spatialagents/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
spatialagents/runner.py,sha256=-EKr5sPnfb17o7pb-QHQ3SQCFA0YdJh69zDar4kdeEk,41811
|
|
15
|
+
spatialagents/schema.py,sha256=w6hpZxk7nA5RpivwRkWRTyWRTc_ecZ6aEX-UWgR4lxc,7252
|
|
16
|
+
spatialagents/serialize.py,sha256=QyrzF3vJUOcA7wSbvoZUAX4gnFC33VXdgnvtdccEXgQ,3128
|
|
17
|
+
spatialagents/sessions.py,sha256=WT_x9XClxOqmjnT8iWyr-QpcgdDvskV2yfLLi4fDd6g,10897
|
|
18
|
+
spatialagents/testing.py,sha256=g2Uway5rSRG9qR9fN-XnXU2Z6vCYSh1IcbkHI5y40WE,46251
|
|
19
|
+
spatialagents/toolcache.py,sha256=kfCRi6hft6HxXiTVAaxDd3dPQfZlxaNGjQLALXbZaho,5503
|
|
20
|
+
spatialagents/tools.py,sha256=Itco43Lr0geYRMBWB6EMJTCfk1aX7IJHwcWyMgJ31Kk,23113
|
|
21
|
+
spatialagents/trace.py,sha256=pbosKXXPAuknaFBCTXxpzAJZ8bhRH7q44FX07-7RbvY,12013
|
|
22
|
+
spatialagents/wire.py,sha256=cA2IwXstMKpHTShaNV3TDvmnyDYQgSdE_luk--Ms1xY,8535
|
|
23
|
+
spatialagents/chat/__init__.py,sha256=dBJ0Ec0xlFPEqg6jI0OB4N_OhI4jZ7mt4a_RfBi8nTw,584
|
|
24
|
+
spatialagents/chat/cli.py,sha256=cC96RnVN1UaF9Rw3Lw-cXV1GQVASbx0e83jma7RL5xU,3747
|
|
25
|
+
spatialagents/chat/http.py,sha256=v6Eg2liN_HA84KAPxjjA-2wOURIHR0weOMnKRJiJNG0,15083
|
|
26
|
+
spatialagents/chat/server.py,sha256=ouV7vUQ9F5VS6guSc2AcCafUuQpsi8Q38r8yM9MCuyE,12721
|
|
27
|
+
spatialagents/chat/static/chat.css,sha256=oM7OG0Ead7hcJvy4mmN-l9G_UZYzzl53tWg7ddQdQ_E,7410
|
|
28
|
+
spatialagents/chat/static/chat.js,sha256=5C5daSjj9khK1F30a6j4nrhdtERhlJ4akEFLNsq5798,42545
|
|
29
|
+
spatialagents/chat/static/index.html,sha256=kdLlCupv6N_XDwdiM03tXUIX1FD5HXTJOX5AoXdqacQ,595
|
|
30
|
+
spatialagents-0.1.0.dist-info/METADATA,sha256=Wy83-s1JIGlDlm2j428PuTQBK3_6XtdHgjmtZOqWCjI,2391
|
|
31
|
+
spatialagents-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
32
|
+
spatialagents-0.1.0.dist-info/entry_points.txt,sha256=q25HYrOGYjrqDHeZLWxUef4O-FmfMofpkE-4TrIhE9Q,49
|
|
33
|
+
spatialagents-0.1.0.dist-info/RECORD,,
|