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/schema.py
ADDED
|
@@ -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
|