spatialagents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ """Answer models -> a JSON schema the provider's structured outputs accept.
2
+
3
+ Structured outputs take a subset of JSON Schema: every object closed
4
+ (`additionalProperties: false`), no numeric or length constraints, no external
5
+ or recursive `$ref`, at most 24 optional properties and 16 unions per request.
6
+ A model's JSON schema (pydantic's `model_json_schema()`) is reduced to that
7
+ subset; the constraints it loses are still enforced, because every answer is
8
+ validated locally against the real model before it is used. A schema that
9
+ cannot fit the limits is not sent at all (`None`): the packet then asks for
10
+ JSON in words, and local validation does the rest.
11
+
12
+ Maps (`dict[str, X]`: verdicts keyed by marker or channel, modules keyed by
13
+ name) have no closed form -- their keys are the packet's -- so a map is sent
14
+ as a list of `{key, value}` entries and `decode` turns such a list back into
15
+ the object the answer model expects before local validation. An answer that
16
+ already gives the object is left alone.
17
+
18
+ This is the mechanism only. Which answer model a packet kind has, and how a
19
+ product's kinds share one schema per task, is the product's (Plexora's
20
+ `harness/schema.py`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import copy
26
+
27
+ DROP = {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minLength",
28
+ "maxLength", "maxItems", "title", "discriminator", "examples", "uniqueItems", "minProperties",
29
+ "maxProperties", "propertyNames"}
30
+ FORMATS = {"date-time", "time", "date", "duration", "email", "hostname", "uri", "ipv4", "ipv6", "uuid"}
31
+ MAX_OPTIONAL = 24
32
+ MAX_UNIONS = 16
33
+
34
+
35
+ #: What a map's entries are called in the provider schema.
36
+ MAP_NOTE = "One entry per key, as {key, value}."
37
+
38
+
39
+ def _map_value(node):
40
+ """The value schema of a map node (`additionalProperties: {...}` and no
41
+ named properties), else None."""
42
+ extra = node.get("additionalProperties") if isinstance(node, dict) else None
43
+ return extra if isinstance(extra, dict) and not node.get("properties") else None
44
+
45
+
46
+ def _resolve(node, defs):
47
+ while isinstance(node, dict) and "$ref" in node:
48
+ name = node["$ref"].rsplit("/", 1)[-1]
49
+ if name not in defs:
50
+ raise ValueError(f"unresolvable $ref {node['$ref']}")
51
+ node = {**copy.deepcopy(defs[name]), **{k: v for k, v in node.items() if k != "$ref"}}
52
+ return node
53
+
54
+
55
+ def _inline(node, defs, depth=0):
56
+ if depth > 24:
57
+ raise ValueError("schema nests too deeply (recursive?)")
58
+ if isinstance(node, list):
59
+ return [_inline(item, defs, depth + 1) for item in node]
60
+ if not isinstance(node, dict):
61
+ return node
62
+ if "$ref" in node:
63
+ return _inline(_resolve(node, defs), defs, depth + 1)
64
+ value = _map_value(node)
65
+ if value is not None:
66
+ described = " ".join(p for p in (node.get("description"), MAP_NOTE) if p)
67
+ return {"type": "array", "description": described,
68
+ "items": {"type": "object", "additionalProperties": False, "required": ["key", "value"],
69
+ "properties": {"key": {"type": "string"},
70
+ "value": _inline(value, defs, depth + 1)}}}
71
+ out = {}
72
+ for key, value in node.items():
73
+ if key == "properties" and isinstance(value, dict):
74
+ # Field names, not keywords: a field called `pattern`, `title` or
75
+ # `format` is kept (dropping it left it `required` but unsayable).
76
+ out[key] = {name: _inline(sub, defs, depth + 1) for name, sub in value.items()}
77
+ continue
78
+ if key in DROP or key in ("$defs", "definitions"):
79
+ continue
80
+ if key == "format" and value not in FORMATS:
81
+ continue
82
+ if key == "minItems" and isinstance(value, int) and value > 1:
83
+ out[key] = 1
84
+ continue
85
+ if key == "pattern":
86
+ continue # regex support is partial; local validation enforces it
87
+ if key == "default" and value is None:
88
+ continue
89
+ out[key] = _inline(value, defs, depth + 1) if key not in ("enum", "const", "required") else value
90
+ if out.get("type") == "object" or "properties" in out:
91
+ out["additionalProperties"] = False
92
+ return out
93
+
94
+
95
+ def _count(node, tally):
96
+ if isinstance(node, list):
97
+ for item in node:
98
+ _count(item, tally)
99
+ return
100
+ if not isinstance(node, dict):
101
+ return
102
+ props = node.get("properties")
103
+ if isinstance(props, dict):
104
+ required = set(node.get("required") or ())
105
+ tally["optional"] += sum(1 for name in props if name not in required)
106
+ if "anyOf" in node or isinstance(node.get("type"), list):
107
+ tally["unions"] += 1
108
+ for value in node.values():
109
+ _count(value, tally)
110
+
111
+
112
+ def structured(model_schema: dict) -> dict | None:
113
+ """The provider-ready schema, or None when it cannot fit the limits."""
114
+ try:
115
+ schema = _inline(model_schema, model_schema.get("$defs") or model_schema.get("definitions") or {})
116
+ except ValueError:
117
+ return None
118
+ tally = {"optional": 0, "unions": 0}
119
+ _count(schema, tally)
120
+ if tally["optional"] > MAX_OPTIONAL or tally["unions"] > MAX_UNIONS:
121
+ return None
122
+ return schema
123
+
124
+
125
+ def _entries(value) -> bool:
126
+ return isinstance(value, list) and all(isinstance(e, dict) and set(e) <= {"key", "value"} and "key" in e
127
+ for e in value)
128
+
129
+
130
+ def _decode(value, node, defs, depth=0):
131
+ if depth > 24 or not isinstance(node, dict):
132
+ return value
133
+ node = _resolve(node, defs)
134
+ if "anyOf" in node:
135
+ for branch in node["anyOf"]:
136
+ branch = _resolve(branch, defs)
137
+ if _map_value(branch) is not None and _entries(value):
138
+ return _decode(value, branch, defs, depth + 1)
139
+ for branch in node["anyOf"]:
140
+ branch = _resolve(branch, defs)
141
+ if isinstance(value, dict) and (branch.get("properties") or _map_value(branch) is not None):
142
+ return _decode(value, branch, defs, depth + 1)
143
+ if isinstance(value, list) and branch.get("type") == "array":
144
+ return _decode(value, branch, defs, depth + 1)
145
+ return value
146
+ inner = _map_value(node)
147
+ if inner is not None:
148
+ if _entries(value):
149
+ value = {str(e["key"]): e.get("value") for e in value}
150
+ if isinstance(value, dict):
151
+ return {k: _decode(v, inner, defs, depth + 1) for k, v in value.items()}
152
+ return value
153
+ props = node.get("properties")
154
+ if isinstance(props, dict) and isinstance(value, dict):
155
+ return {k: _decode(v, props[k], defs, depth + 1) if k in props else v for k, v in value.items()}
156
+ if node.get("type") == "array" and isinstance(value, list) and isinstance(node.get("items"), dict):
157
+ return [_decode(v, node["items"], defs, depth + 1) for v in value]
158
+ return value
159
+
160
+
161
+ def decode(answer, model_schema: dict):
162
+ """An answer given against `structured(model_schema)` back in the answer
163
+ model's own shape: every map sent as `{key, value}` entries is an object
164
+ again. Anything else passes through unchanged."""
165
+ return _decode(answer, model_schema, model_schema.get("$defs") or model_schema.get("definitions") or {})
166
+
167
+
@@ -0,0 +1,88 @@
1
+ """Turning a tool's result into what goes to the model -- bounded.
2
+
3
+ A model's context is the scarce resource. A result is serialized as compact
4
+ JSON; when it would exceed the limit, its longest lists are halved until it
5
+ fits, and the result says `truncated: true` with the paths that were cut --
6
+ never a silently shorter answer. (The same rule as a product's MCP server
7
+ applies to what it sends an external agent, so a result reads the same
8
+ wherever it went.)
9
+
10
+ `clean` is the default rendering of a value as JSON-able data: non-finite
11
+ floats become null, bytes a size, numpy-style scalars their `.item()`. A
12
+ backend whose results carry other types renders them in its own
13
+ `ToolBackend.serialize`, and this module bounds what comes back.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import math
20
+ from typing import Any
21
+
22
+ #: Characters in one serialized tool result, unless the backend says otherwise.
23
+ MAX_RESULT_CHARS = 60_000
24
+
25
+
26
+ def clean(value):
27
+ if isinstance(value, float):
28
+ return value if math.isfinite(value) else None
29
+ if isinstance(value, dict):
30
+ return {str(k): clean(v) for k, v in value.items()}
31
+ if isinstance(value, (list, tuple)):
32
+ return [clean(v) for v in value]
33
+ if isinstance(value, (bytes, bytearray)):
34
+ return f"<{len(value)} bytes>"
35
+ if hasattr(value, "item") and callable(value.item):
36
+ try:
37
+ return clean(value.item())
38
+ except Exception:
39
+ return str(value)
40
+ return value
41
+
42
+
43
+ def dumps(value) -> str:
44
+ return json.dumps(clean(value), separators=(",", ":"), ensure_ascii=False, default=str)
45
+
46
+
47
+ def _lists(value, path=""):
48
+ if isinstance(value, dict):
49
+ for key, item in value.items():
50
+ yield from _lists(item, f"{path}.{key}" if path else str(key))
51
+ elif isinstance(value, list):
52
+ yield path, value
53
+ for index, item in enumerate(value[:20]):
54
+ yield from _lists(item, f"{path}[{index}]")
55
+
56
+
57
+ def bound(value: Any, limit: int = MAX_RESULT_CHARS) -> str:
58
+ """Compact JSON of `value`, at most about `limit` characters."""
59
+ value = clean(value)
60
+ text = dumps(value)
61
+ if len(text) <= limit:
62
+ return text
63
+ cut = []
64
+ for _ in range(64):
65
+ candidates = [(len(dumps(items)), path, items) for path, items in _lists(value)
66
+ if len(items) > 1]
67
+ if not candidates:
68
+ break
69
+ _size, path, items = max(candidates, key=lambda c: c[0])
70
+ keep = max(1, len(items) // 2)
71
+ dropped = len(items) - keep
72
+ del items[keep:]
73
+ cut.append({"path": path, "dropped": dropped})
74
+ if isinstance(value, dict):
75
+ value["truncated"] = True
76
+ value["_truncated"] = cut
77
+ text = dumps(value)
78
+ if len(text) <= limit:
79
+ return text
80
+ return text[:limit]
81
+
82
+
83
+ def indented(value) -> str:
84
+ """`value` as JSON indented one space per level: what an offloaded result
85
+ is stored as, so a query matches a line that means something."""
86
+ if isinstance(value, str):
87
+ return value
88
+ return json.dumps(clean(value), indent=1, ensure_ascii=False, default=str)
@@ -0,0 +1,276 @@
1
+ """A session on disk: the record, its packets, its decisions, and who holds it.
2
+
3
+ <root>/<session_id>/
4
+ session.json the record (atomic writes)
5
+ control.json pause / stop / approvals, written by the person's routes
6
+ decisions.jsonl every packet issued and every answer applied (a
7
+ conversation's numbered transcript)
8
+ packets/<id>.json the outstanding (and recent) packets
9
+ packets/<id>/ their images, as sent
10
+ .lock the process driving the session (pid)
11
+
12
+ The root is the product's, one per kind of session (Plexora's
13
+ `<data_root>/.agent/sessions/<kind>/`, its conversations'
14
+ `<data_root>/.agent/ai/conversations/`). The thread that runs a session's bulk
15
+ job and the tool calls answering its packets are in one process; `lock`
16
+ serialises them. A second process that tries to drive the same session is
17
+ refused (`conflict`) while the first is alive, and takes over when it is not.
18
+
19
+ A product names itself in what a refused driver is told (`HOLDER`) and where an
20
+ unknown session id's error points (`LIST_HINT`) by subclassing.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import shutil
28
+ import threading
29
+ import time
30
+ import uuid
31
+ from contextlib import contextmanager
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ from spatialagents.errors import AgentError
36
+
37
+ KEEP_SESSIONS = 20
38
+ KEEP_DAYS = 60
39
+
40
+ _LOCKS: dict = {}
41
+ _GUARD = threading.Lock()
42
+
43
+
44
+ def new_session_id(prefix="gs") -> str:
45
+ return f"{prefix}_{datetime.now(timezone.utc):%Y%m%dT%H%M%S}_{uuid.uuid4().hex[:6]}"
46
+
47
+
48
+ def atomic_write(path: Path, text: str):
49
+ """`text` into `path`, whole or not at all."""
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
52
+ tmp.write_text(text, encoding="utf-8")
53
+ # Windows refuses to replace a file another thread has open for reading
54
+ # (WinError 5/32) -- the bulk job and an answering loop read session.json
55
+ # while the other writes it, and parallel sessions make that common. The
56
+ # reader holds it for microseconds, so a short retry always gets through.
57
+ for attempt in range(40):
58
+ try:
59
+ os.replace(tmp, path)
60
+ return
61
+ except PermissionError:
62
+ if attempt == 39:
63
+ raise
64
+ time.sleep(0.005 * (attempt + 1))
65
+
66
+
67
+ def _pid_alive(pid) -> bool:
68
+ try:
69
+ pid = int(pid)
70
+ except (TypeError, ValueError):
71
+ return False
72
+ if pid == os.getpid():
73
+ return True
74
+ try:
75
+ os.kill(pid, 0)
76
+ except ProcessLookupError:
77
+ return False
78
+ except PermissionError:
79
+ return True
80
+ except OSError:
81
+ return False
82
+ return True
83
+
84
+
85
+ class SessionBusy(Exception):
86
+ """`SessionStore.lock(timeout=...)` ran out: another thread holds the session."""
87
+
88
+
89
+ class SessionStore:
90
+ """The sessions of one kind under one root."""
91
+
92
+ #: Who a second process trying to drive a held session is told holds it.
93
+ HOLDER = "another process"
94
+ #: Where an unknown session id's error points, if anywhere.
95
+ LIST_HINT: str | None = None
96
+
97
+ def __init__(self, kind: str, root: Path | str):
98
+ self.kind = kind
99
+ self.root = Path(root)
100
+
101
+ # -- paths -----------------------------------------------------------
102
+
103
+ def folder(self, session_id) -> Path:
104
+ if not str(session_id).replace("_", "").replace("-", "").isalnum():
105
+ raise AgentError("invalid_input", f"not a session id: {session_id!r}")
106
+ return self.root / str(session_id)
107
+
108
+ def exists(self, session_id) -> bool:
109
+ return (self.folder(session_id) / "session.json").is_file()
110
+
111
+ # -- the record ------------------------------------------------------
112
+
113
+ def create(self, record: dict) -> dict:
114
+ folder = self.folder(record["session_id"])
115
+ folder.mkdir(parents=True, exist_ok=False)
116
+ self.save(record)
117
+ self.claim(record["session_id"])
118
+ return record
119
+
120
+ def load(self, session_id) -> dict:
121
+ path = self.folder(session_id) / "session.json"
122
+ if not path.is_file():
123
+ raise AgentError("invalid_input", f"no {self.kind} session {session_id!r}",
124
+ detail={"hint": self.LIST_HINT} if self.LIST_HINT else None)
125
+ return json.loads(path.read_text(encoding="utf-8"))
126
+
127
+ def save(self, record: dict):
128
+ record["updated_at"] = time.time()
129
+ atomic_write(self.folder(record["session_id"]) / "session.json",
130
+ json.dumps(record, default=str, ensure_ascii=False))
131
+
132
+ @contextmanager
133
+ def lock(self, session_id, *, timeout: float = -1):
134
+ """Exclusive access to one session's record within this process.
135
+
136
+ `timeout` (seconds) bounds the wait and raises `SessionBusy` when it
137
+ runs out; the default waits as long as it takes. Best-effort work
138
+ that runs while holding another lock (a bulk pass's progress, inside
139
+ an open image reader) must not wait: an answer drawing the next
140
+ packet holds this lock and waits for that reader."""
141
+ with _GUARD:
142
+ lock = _LOCKS.setdefault((str(self.root), str(session_id)), threading.RLock())
143
+ if not lock.acquire(timeout=timeout):
144
+ raise SessionBusy(session_id)
145
+ try:
146
+ yield
147
+ finally:
148
+ lock.release()
149
+
150
+ # -- ownership across processes ---------------------------------------
151
+
152
+ def claim(self, session_id):
153
+ """Take the session for this process, or refuse while another live
154
+ process holds it."""
155
+ path = self.folder(session_id) / ".lock"
156
+ if path.exists():
157
+ try:
158
+ holder = json.loads(path.read_text(encoding="utf-8"))
159
+ except (OSError, ValueError):
160
+ holder = {}
161
+ if holder.get("pid") != os.getpid() and _pid_alive(holder.get("pid")):
162
+ raise AgentError("conflict", f"{self.HOLDER} is driving this session",
163
+ detail={"holder_pid": holder.get("pid"),
164
+ "since": holder.get("since")}, retryable=True)
165
+ atomic_write(path, json.dumps({"pid": os.getpid(), "since": time.time()}))
166
+
167
+ def release(self, session_id):
168
+ path = self.folder(session_id) / ".lock"
169
+ try:
170
+ holder = json.loads(path.read_text(encoding="utf-8"))
171
+ if holder.get("pid") == os.getpid():
172
+ path.unlink()
173
+ except (OSError, ValueError):
174
+ pass
175
+
176
+ # -- control -----------------------------------------------------------
177
+
178
+ def control(self, session_id) -> dict:
179
+ path = self.folder(session_id) / "control.json"
180
+ if not path.is_file():
181
+ return {"paused": False, "paused_by": None, "locked_markers": []}
182
+ try:
183
+ return json.loads(path.read_text(encoding="utf-8"))
184
+ except (OSError, ValueError):
185
+ return {"paused": False, "paused_by": None, "locked_markers": []}
186
+
187
+ def set_control(self, session_id, **changes) -> dict:
188
+ control = self.control(session_id)
189
+ control.update(changes)
190
+ atomic_write(self.folder(session_id) / "control.json", json.dumps(control))
191
+ return control
192
+
193
+ # -- packets and decisions ---------------------------------------------
194
+
195
+ def write_packet(self, session_id, packet: dict, images=()):
196
+ folder = self.folder(session_id) / "packets"
197
+ atomic_write(folder / f"{packet['packet_id']}.json", json.dumps(packet, default=str))
198
+ image_dir = folder / packet["packet_id"]
199
+ if image_dir.is_dir():
200
+ # A re-rendered packet replaces its images; none may linger.
201
+ for stale in image_dir.iterdir():
202
+ stale.unlink()
203
+ if images:
204
+ image_dir.mkdir(parents=True, exist_ok=True)
205
+ for index, (data, fmt) in enumerate(images):
206
+ (image_dir / f"{index}.{fmt}").write_bytes(data)
207
+
208
+ def read_packet(self, session_id, packet_id) -> tuple:
209
+ """(packet, [(bytes, format)])."""
210
+ folder = self.folder(session_id) / "packets"
211
+ path = folder / f"{packet_id}.json"
212
+ if not path.is_file():
213
+ raise AgentError("invalid_input", f"no packet {packet_id!r} in this session")
214
+ packet = json.loads(path.read_text(encoding="utf-8"))
215
+ images = []
216
+ image_dir = folder / packet_id
217
+ if image_dir.is_dir():
218
+ for file in sorted(image_dir.iterdir(), key=lambda p: int(p.stem)
219
+ if p.stem.isdigit() else 0):
220
+ images.append((file.read_bytes(), file.suffix.lstrip(".")))
221
+ return packet, images
222
+
223
+ def log(self, session_id, record: dict):
224
+ path = self.folder(session_id) / "decisions.jsonl"
225
+ path.parent.mkdir(parents=True, exist_ok=True)
226
+ line = json.dumps({"t": time.time(), **record}, default=str, ensure_ascii=False)
227
+ with open(path, "a", encoding="utf-8") as handle:
228
+ handle.write(line + "\n")
229
+
230
+ def decisions(self, session_id) -> list:
231
+ path = self.folder(session_id) / "decisions.jsonl"
232
+ if not path.is_file():
233
+ return []
234
+ out = []
235
+ for line in path.read_text(encoding="utf-8").splitlines():
236
+ try:
237
+ out.append(json.loads(line))
238
+ except ValueError:
239
+ continue
240
+ return out
241
+
242
+ def clear_packets(self, session_id):
243
+ folder = self.folder(session_id) / "packets"
244
+ if folder.is_dir():
245
+ shutil.rmtree(folder, ignore_errors=True)
246
+
247
+ # -- listing and retention ---------------------------------------------
248
+
249
+ def list(self, limit=20) -> list:
250
+ if not self.root.is_dir():
251
+ return []
252
+ records = []
253
+ for folder in self.root.iterdir():
254
+ path = folder / "session.json"
255
+ if not path.is_file():
256
+ continue
257
+ try:
258
+ record = json.loads(path.read_text(encoding="utf-8"))
259
+ except (OSError, ValueError):
260
+ continue
261
+ records.append(record)
262
+ records.sort(key=lambda r: r.get("created_at") or "", reverse=True)
263
+ return records[:limit]
264
+
265
+ def sweep(self, keep=KEEP_SESSIONS, days=KEEP_DAYS, now=None) -> int:
266
+ """Remove finished sessions past the newest `keep` and older than `days`."""
267
+ now = now or time.time()
268
+ records = self.list(limit=10_000)
269
+ removed = 0
270
+ for index, record in enumerate(records):
271
+ finished = record.get("state") in ("done", "cancelled", "failed", "rolled_back")
272
+ old = now - float(record.get("updated_at") or now) > days * 86400
273
+ if finished and (index >= keep or old):
274
+ shutil.rmtree(self.folder(record["session_id"]), ignore_errors=True)
275
+ removed += 1
276
+ return removed