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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ spatialagents = spatialagents.testing