spatialagents 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialagents/__init__.py +66 -0
- spatialagents/approvals.py +137 -0
- spatialagents/backend.py +181 -0
- spatialagents/cache_plan.py +117 -0
- spatialagents/chat/__init__.py +11 -0
- spatialagents/chat/cli.py +84 -0
- spatialagents/chat/http.py +308 -0
- spatialagents/chat/server.py +269 -0
- spatialagents/chat/static/chat.css +219 -0
- spatialagents/chat/static/chat.js +933 -0
- spatialagents/chat/static/index.html +16 -0
- spatialagents/conversations.py +292 -0
- spatialagents/errors.py +73 -0
- spatialagents/gateway.py +192 -0
- spatialagents/offload.py +92 -0
- spatialagents/orchestrator.py +259 -0
- spatialagents/plans.py +800 -0
- spatialagents/policy.py +118 -0
- spatialagents/prefix.py +79 -0
- spatialagents/py.typed +0 -0
- spatialagents/runner.py +813 -0
- spatialagents/schema.py +167 -0
- spatialagents/serialize.py +88 -0
- spatialagents/sessions.py +276 -0
- spatialagents/testing.py +927 -0
- spatialagents/toolcache.py +144 -0
- spatialagents/tools.py +460 -0
- spatialagents/trace.py +237 -0
- spatialagents/wire.py +194 -0
- spatialagents-0.1.0.dist-info/METADATA +55 -0
- spatialagents-0.1.0.dist-info/RECORD +33 -0
- spatialagents-0.1.0.dist-info/WHEEL +4 -0
- spatialagents-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The chat harness runtime BioCognia's products share.
|
|
2
|
+
|
|
3
|
+
A product (Plexora, SCIMAP Pro) brings its tools, its words and its places on
|
|
4
|
+
disk; this package is the rest of an in-app AI conversation: the streamed
|
|
5
|
+
model call through the BioCognia AI gateway, the tool loop with deferred
|
|
6
|
+
schemas, approvals, sub-agents, compaction, the tool-result cache, offloaded
|
|
7
|
+
results, the conversation store and the local trace. It never imports a
|
|
8
|
+
product. A product binds it through a `backend.ToolBackend` (what its tools
|
|
9
|
+
are and how one runs), a `runner.ChatProfile` (its prompt content, task names
|
|
10
|
+
and wording), a `plans.PlanHost` (its two plan packets and its workflow layer)
|
|
11
|
+
and the roots, version and callbacks each store takes.
|
|
12
|
+
|
|
13
|
+
wire the request envelope, canonical JSON, usage, task ids on the wire
|
|
14
|
+
gateway the streamed model call, on biocognia.gateway's token and transport
|
|
15
|
+
cache_plan prefix fingerprints and the cache-hit monitor
|
|
16
|
+
schema answer models -> structured-output JSON schema, and back
|
|
17
|
+
orchestrator task graph, parallel scheduler, shared blackboard
|
|
18
|
+
trace the local trace: runs, model calls, cache verdicts, tool calls
|
|
19
|
+
policy the permission and egress vocabulary, and `Policy`
|
|
20
|
+
errors `AgentError` and its codes (a product registers its own)
|
|
21
|
+
sessions a session on disk: record, control, transcript, packets, lock
|
|
22
|
+
conversations conversations on disk, and the service that runs their turns
|
|
23
|
+
approvals source-file writes and deletes wait for the user (control.json)
|
|
24
|
+
toolcache read results remembered per revision
|
|
25
|
+
offload large tool results as stubs, read back in slices
|
|
26
|
+
serialize results as bounded JSON
|
|
27
|
+
backend the `ToolBackend` protocol a product implements
|
|
28
|
+
tools a backend's tools as deferred tools (load_tool / call_tool)
|
|
29
|
+
prefix the cached system prompt's mechanism
|
|
30
|
+
runner the conversational agent: a tool loop, sub-agents, compaction
|
|
31
|
+
plans the plan runner: compose and summarise packets over a product's PlanHost
|
|
32
|
+
chat.http the chat's HTTP + SSE contract (ChatApi), framework-free
|
|
33
|
+
chat.server the page and the API on a loopback port, stdlib only
|
|
34
|
+
chat.cli the terminal loop
|
|
35
|
+
testing FakeGateway, FakeToolBackend, FakePlanHost, ScriptedBrain (and inert fixtures)
|
|
36
|
+
|
|
37
|
+
Every submodule is imported on first use, so `import spatialagents` costs
|
|
38
|
+
nothing. Model calls are refused before a socket opens under
|
|
39
|
+
`BIOCOGNIA_OFFLINE=1`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import importlib
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
_SUBMODULES = frozenset({
|
|
49
|
+
"approvals", "backend", "cache_plan", "chat", "conversations", "errors", "gateway", "offload",
|
|
50
|
+
"orchestrator", "plans", "policy", "prefix", "runner", "schema", "serialize", "sessions", "testing",
|
|
51
|
+
"toolcache", "tools", "trace", "wire",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
__all__ = ["__version__", *sorted(_SUBMODULES)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def __getattr__(name: str):
|
|
58
|
+
if name in _SUBMODULES:
|
|
59
|
+
module = importlib.import_module(f"{__name__}.{name}")
|
|
60
|
+
globals()[name] = module
|
|
61
|
+
return module
|
|
62
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __dir__():
|
|
66
|
+
return sorted(set(globals()) | _SUBMODULES)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Human in the loop: a call that writes the user's source file or cannot be
|
|
2
|
+
undone waits for the user.
|
|
3
|
+
|
|
4
|
+
The permission classes decide (`policy.PERMISSIONS`); a call's class is the
|
|
5
|
+
backend's answer for those arguments (`ToolBackend.permission`), so one tool
|
|
6
|
+
may read in one call and write a source file in the next:
|
|
7
|
+
|
|
8
|
+
read runs
|
|
9
|
+
reversible_write runs; its operation_id goes to the person as an Undo chip
|
|
10
|
+
source_file_write waits for an approval
|
|
11
|
+
destructive waits for an approval
|
|
12
|
+
|
|
13
|
+
`ApprovalGate.ask` writes a pending approval into the conversation's
|
|
14
|
+
`control.json` (`approvals.<id>`, and `paused: true, paused_by: "approval"`),
|
|
15
|
+
announces `approval_requested`, and waits for the record to change. The
|
|
16
|
+
person's Approve / Deny (a panel's button posting to the product's route, a
|
|
17
|
+
terminal's y/N) calls `decide`. Approve runs THAT one call with the policy
|
|
18
|
+
flag it needs (`policy.elevated`) and the backend's confirmation
|
|
19
|
+
(`ToolBackend.invoke(..., approved=True)`); Deny, a stop or an expiry return an
|
|
20
|
+
error tool_result ("the user declined") so the model knows not to try another
|
|
21
|
+
way round. Sub-agents share their parent's gate, so one sub-agent's approval
|
|
22
|
+
pauses the whole conversation.
|
|
23
|
+
|
|
24
|
+
Model output cannot authorise its own writes: `decide` is reached only from a
|
|
25
|
+
surface the person holds, never from a tool the model can call.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
|
|
33
|
+
from spatialagents.errors import AgentError
|
|
34
|
+
from spatialagents.policy import elevated
|
|
35
|
+
|
|
36
|
+
__all__ = ["DECIDED", "DECLINED", "PENDING", "ApprovalGate", "decide", "elevated", "new_approval_id"]
|
|
37
|
+
|
|
38
|
+
PENDING = "pending"
|
|
39
|
+
DECIDED = ("approved", "denied", "stopped", "expired")
|
|
40
|
+
DECLINED = "The user declined this call. Do not try to reach the same result another way; say what " \
|
|
41
|
+
"you would have done and continue without it."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def new_approval_id() -> str:
|
|
45
|
+
return f"apr_{uuid.uuid4().hex[:10]}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pending(control: dict) -> list:
|
|
49
|
+
return [a for a in (control.get("approvals") or {}).values() if a.get("status") == PENDING]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decide(store, conversation_id: str, approval_id: str, approve: bool, *, by: str = "viewer") -> dict:
|
|
53
|
+
"""Record the user's answer. Raises `invalid_input` for an unknown approval
|
|
54
|
+
and `conflict` for one already decided."""
|
|
55
|
+
with store.lock(conversation_id):
|
|
56
|
+
control = store.control(conversation_id)
|
|
57
|
+
approvals = dict(control.get("approvals") or {})
|
|
58
|
+
approval = approvals.get(approval_id)
|
|
59
|
+
if approval is None:
|
|
60
|
+
raise AgentError("invalid_input", f"no approval {approval_id!r} in this conversation")
|
|
61
|
+
if approval.get("status") != PENDING:
|
|
62
|
+
raise AgentError("conflict", f"{approval_id} was already {approval.get('status')}",
|
|
63
|
+
detail={"status": approval.get("status")})
|
|
64
|
+
approval = {**approval, "status": "approved" if approve else "denied", "decided_by": by,
|
|
65
|
+
"decided_at": time.time()}
|
|
66
|
+
approvals[approval_id] = approval
|
|
67
|
+
changes = {"approvals": approvals}
|
|
68
|
+
others = [a for a in approvals.values() if a.get("status") == PENDING]
|
|
69
|
+
if not others and control.get("paused_by") == "approval":
|
|
70
|
+
changes.update(paused=False, paused_by=None)
|
|
71
|
+
store.set_control(conversation_id, **changes)
|
|
72
|
+
return approval
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ApprovalGate:
|
|
76
|
+
def __init__(self, store, conversation_id: str, *, poll_s: float = 0.1, timeout_s: float = 3600.0):
|
|
77
|
+
self.store = store
|
|
78
|
+
self.conversation_id = conversation_id
|
|
79
|
+
self.poll_s = poll_s
|
|
80
|
+
self.timeout_s = timeout_s
|
|
81
|
+
|
|
82
|
+
def request(self, *, tool: str, capability: str, permission: str, purpose: str = "", arguments: dict,
|
|
83
|
+
agent: str | None = None) -> dict:
|
|
84
|
+
approval = {"approval_id": new_approval_id(), "tool": tool, "capability": capability,
|
|
85
|
+
"permission": permission, "purpose": (purpose or "")[:300],
|
|
86
|
+
"arguments": arguments, "agent": agent, "status": PENDING, "requested_at": time.time()}
|
|
87
|
+
with self.store.lock(self.conversation_id):
|
|
88
|
+
control = self.store.control(self.conversation_id)
|
|
89
|
+
approvals = dict(control.get("approvals") or {})
|
|
90
|
+
approvals[approval["approval_id"]] = approval
|
|
91
|
+
self.store.set_control(self.conversation_id, approvals=approvals, paused=True,
|
|
92
|
+
paused_by="approval")
|
|
93
|
+
return approval
|
|
94
|
+
|
|
95
|
+
def wait(self, approval_id: str) -> str:
|
|
96
|
+
deadline = time.monotonic() + self.timeout_s
|
|
97
|
+
while True:
|
|
98
|
+
control = self.store.control(self.conversation_id)
|
|
99
|
+
approval = (control.get("approvals") or {}).get(approval_id) or {}
|
|
100
|
+
status = approval.get("status")
|
|
101
|
+
if status in DECIDED:
|
|
102
|
+
return status
|
|
103
|
+
if control.get("stopped"):
|
|
104
|
+
self._close(approval_id, "stopped")
|
|
105
|
+
return "stopped"
|
|
106
|
+
if time.monotonic() >= deadline:
|
|
107
|
+
self._close(approval_id, "expired")
|
|
108
|
+
return "expired"
|
|
109
|
+
time.sleep(self.poll_s)
|
|
110
|
+
|
|
111
|
+
def _close(self, approval_id: str, status: str) -> None:
|
|
112
|
+
try:
|
|
113
|
+
with self.store.lock(self.conversation_id):
|
|
114
|
+
control = self.store.control(self.conversation_id)
|
|
115
|
+
approvals = dict(control.get("approvals") or {})
|
|
116
|
+
if approval_id in approvals and approvals[approval_id].get("status") == PENDING:
|
|
117
|
+
approvals[approval_id] = {**approvals[approval_id], "status": status,
|
|
118
|
+
"decided_at": time.time()}
|
|
119
|
+
changes = {"approvals": approvals}
|
|
120
|
+
if not [a for a in approvals.values() if a.get("status") == PENDING] \
|
|
121
|
+
and control.get("paused_by") == "approval":
|
|
122
|
+
changes.update(paused=False, paused_by=None)
|
|
123
|
+
self.store.set_control(self.conversation_id, **changes)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def ask(self, *, tool: str, capability: str, permission: str, purpose: str = "", arguments: dict, emit,
|
|
128
|
+
agent: str | None = None) -> tuple:
|
|
129
|
+
"""`(status, approval)`: request, announce through `emit`, and wait."""
|
|
130
|
+
approval = self.request(tool=tool, capability=capability, permission=permission, purpose=purpose,
|
|
131
|
+
arguments=arguments, agent=agent)
|
|
132
|
+
emit({"event": "approval_requested", **{k: approval[k] for k in (
|
|
133
|
+
"approval_id", "tool", "capability", "permission", "purpose", "arguments", "agent")}})
|
|
134
|
+
status = self.wait(approval["approval_id"])
|
|
135
|
+
emit({"event": "approval_decided", "approval_id": approval["approval_id"], "status": status,
|
|
136
|
+
"agent": agent})
|
|
137
|
+
return status, approval
|
spatialagents/backend.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""What a product's tools are to the harness: the `ToolBackend` protocol.
|
|
2
|
+
|
|
3
|
+
The conversational loop (`runner.py`) and its tools (`tools.py`) never touch a
|
|
4
|
+
product's registry. Everything they need from it goes through one object a
|
|
5
|
+
product implements -- Plexora's `RegistryBackend` over its capability
|
|
6
|
+
registry, SCIMAP Pro's `ScimapBackend` over its MCP toolset -- and this
|
|
7
|
+
protocol is the whole of that contract:
|
|
8
|
+
|
|
9
|
+
catalog(policy, viewer=) the tools a conversation may use, frozen at its start
|
|
10
|
+
definition(tool) one tool's {name, description, input_schema}
|
|
11
|
+
permission(tool, arguments) its permission class FOR THESE ARGUMENTS
|
|
12
|
+
invoke(tool, arguments, policy=, approved=) run it; an outcome dict, never raises
|
|
13
|
+
purpose(tool) what it does, for the person approving it
|
|
14
|
+
cacheable(tool, arguments) whether its result may be remembered
|
|
15
|
+
revision_token(tool, arguments) what a remembered result is valid for
|
|
16
|
+
skills() / read_skill(name) the product's runtime skills
|
|
17
|
+
server_info(policy, tools=, viewer=) what `server_info` answers in a conversation
|
|
18
|
+
serialize(value) a result as JSON-able data
|
|
19
|
+
split_images(result) (result without images, [(bytes, format)])
|
|
20
|
+
|
|
21
|
+
and two attributes, `max_result_chars` (how long one serialized result may
|
|
22
|
+
be) and `revision_guard` (see below).
|
|
23
|
+
|
|
24
|
+
**Permission depends on the arguments.** SCIMAP Pro's `run_function` is a
|
|
25
|
+
`reversible_write` on an in-memory table and a `source_file_write` when the
|
|
26
|
+
table is out of core, so the class is asked per call; the catalog's
|
|
27
|
+
`permission` is what the tool usually is, and only marks the catalog line.
|
|
28
|
+
`read` runs, `reversible_write` runs and its `operation_id` becomes an Undo
|
|
29
|
+
chip, `source_file_write` and `destructive` wait for the person
|
|
30
|
+
(`approvals.py`). Whether the call may run at all is still the backend's
|
|
31
|
+
check in `invoke`: the harness never decides what is scientifically allowed.
|
|
32
|
+
|
|
33
|
+
**`invoke` with `approved=True`** is a call the person approved: the policy it
|
|
34
|
+
is given already carries the flag the class needs (`policy.elevated`), and
|
|
35
|
+
the backend confirms the call the way its own tools are confirmed (Plexora
|
|
36
|
+
adds `confirm: true` where a capability takes one). Nothing else may pass
|
|
37
|
+
`approved=True`.
|
|
38
|
+
|
|
39
|
+
**The outcome** of `invoke`:
|
|
40
|
+
|
|
41
|
+
{"ok": True, "result": <data>, "operation_id": str | None, "undo": bool,
|
|
42
|
+
"advanced": {"scope": str, "before": str, "after": str} | None}
|
|
43
|
+
{"ok": False, "error": {"code", "message", "detail", "retryable"}, "operation_id": str | None}
|
|
44
|
+
|
|
45
|
+
`result` is JSON-able apart from image bytes, which `split_images` lifts out
|
|
46
|
+
(the default reads them from `result["_images"]`). `undo` says the write has
|
|
47
|
+
an exact reversal the person can ask for; `advanced` says it moved a revision
|
|
48
|
+
guard from `before` to `after` (below). A domain failure is an outcome, not an
|
|
49
|
+
exception: the model reads the error and tries something else.
|
|
50
|
+
|
|
51
|
+
**Revision guards.** A product whose writes carry an optimistic-concurrency
|
|
52
|
+
guard names its two arguments in `revision_guard` -- `(scope, expected)`,
|
|
53
|
+
Plexora's `("project", "expected_revision")` -- and reports each write's move
|
|
54
|
+
in `advanced`. Writes a model sends together each name the revision it read
|
|
55
|
+
before the first ran; the runner carries each one forward past the turn's own
|
|
56
|
+
earlier writes, and only those (`AgentRunner._carry_revision`).
|
|
57
|
+
|
|
58
|
+
Every method but the first four has a default here, so a backend subclasses
|
|
59
|
+
`ToolBackend` and implements what it has. `testing.FakeToolBackend` is the
|
|
60
|
+
reference implementation, and the compatibility contract both products'
|
|
61
|
+
backends are tested against.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
from typing import Any, Protocol
|
|
67
|
+
|
|
68
|
+
from spatialagents import serialize as _serialize
|
|
69
|
+
from spatialagents.errors import AgentError
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_images(result) -> tuple:
|
|
73
|
+
"""(result without images, [(bytes, format)]) -- images travel as image
|
|
74
|
+
content. `result["_images"]` holds PNG bytes, or `{data, format}` for
|
|
75
|
+
another format (WebP: the same pixels, a third of the bytes)."""
|
|
76
|
+
if not isinstance(result, dict):
|
|
77
|
+
return result, []
|
|
78
|
+
images = result.pop("_images", None) or []
|
|
79
|
+
out = []
|
|
80
|
+
for image in images:
|
|
81
|
+
if isinstance(image, (bytes, bytearray)):
|
|
82
|
+
out.append((bytes(image), "png"))
|
|
83
|
+
elif isinstance(image, dict) and isinstance(image.get("data"), (bytes, bytearray)):
|
|
84
|
+
out.append((bytes(image["data"]), str(image.get("format") or "png")))
|
|
85
|
+
return result, out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ok(result=None, *, operation_id: str | None = None, undo: bool = False, advanced: dict | None = None) -> dict:
|
|
89
|
+
"""A successful outcome."""
|
|
90
|
+
out = {"ok": True, "result": result, "operation_id": operation_id}
|
|
91
|
+
if undo:
|
|
92
|
+
out["undo"] = True
|
|
93
|
+
if advanced:
|
|
94
|
+
out["advanced"] = dict(advanced)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def failed(code: str, message: str, *, detail=None, retryable: bool = False,
|
|
99
|
+
operation_id: str | None = None) -> dict:
|
|
100
|
+
"""A refused or failed outcome, with a code from `errors.known()`."""
|
|
101
|
+
return {"ok": False, "error": AgentError(code, message, detail=detail, retryable=retryable).to_problem(),
|
|
102
|
+
"operation_id": operation_id}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refusal(exc: AgentError, *, operation_id: str | None = None) -> dict:
|
|
106
|
+
"""`exc` as an outcome."""
|
|
107
|
+
return {"ok": False, "error": exc.to_problem(), "operation_id": operation_id}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ToolBackend(Protocol):
|
|
111
|
+
"""A product's tools, as the harness drives them."""
|
|
112
|
+
|
|
113
|
+
#: Characters in one serialized tool result (the product's MCP server's own limit).
|
|
114
|
+
max_result_chars: int = _serialize.MAX_RESULT_CHARS
|
|
115
|
+
#: `(scope argument, expected-revision argument)` of a write's revision guard, or None.
|
|
116
|
+
revision_guard: tuple[str, str] | None = None
|
|
117
|
+
|
|
118
|
+
def catalog(self, policy, *, viewer: bool = False) -> list[dict]:
|
|
119
|
+
"""The tools a conversation under `policy` may use: `{"tool", "capability",
|
|
120
|
+
"permission", "purpose"}` each (`capability` defaults to the tool's
|
|
121
|
+
name). Every tool the policy could let through -- egress allowed,
|
|
122
|
+
writes only when it allows writes, source-file writes and deletes
|
|
123
|
+
included, because they pause for approval rather than being refused.
|
|
124
|
+
`viewer`: a live view is attached, so tools that drive it may be offered.
|
|
125
|
+
The harness sorts it, drops names its own local tools use, and freezes
|
|
126
|
+
it for the conversation."""
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
def definition(self, tool: str) -> dict:
|
|
130
|
+
"""`{"name", "description", "input_schema"}`: what `load_tool` hands the
|
|
131
|
+
model. The input schema is a JSON schema (pydantic's is fine: titles
|
|
132
|
+
are stripped by the harness)."""
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
def permission(self, tool: str, arguments: dict) -> str:
|
|
136
|
+
"""This call's permission class (`policy.PERMISSIONS`)."""
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
|
|
139
|
+
def invoke(self, tool: str, arguments: dict, *, policy, approved: bool = False) -> dict:
|
|
140
|
+
"""Run one call; the outcome described above. Validates the arguments,
|
|
141
|
+
checks the policy and the licence, writes the product's audit -- as
|
|
142
|
+
any other surface of the product would."""
|
|
143
|
+
raise NotImplementedError
|
|
144
|
+
|
|
145
|
+
def purpose(self, tool: str) -> str:
|
|
146
|
+
"""What the tool does, for the person deciding whether to let it run."""
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
def cacheable(self, tool: str, arguments: dict) -> bool:
|
|
150
|
+
"""Whether this call's result may be remembered (`toolcache.py`): a
|
|
151
|
+
read of something with a revision, never a write, never cell-level
|
|
152
|
+
data, never what a live view shows."""
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def revision_token(self, tool: str, arguments: dict) -> str:
|
|
156
|
+
"""What a remembered result of this call is valid for: it changes
|
|
157
|
+
whenever a write changes what the call reads."""
|
|
158
|
+
return "0"
|
|
159
|
+
|
|
160
|
+
def skills(self) -> list[dict]:
|
|
161
|
+
"""The runtime skills a conversation may read: `{"name", "title", "when"}` each."""
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
def read_skill(self, name: str) -> str:
|
|
165
|
+
"""One skill's text. Raises KeyError for a skill there is not."""
|
|
166
|
+
raise KeyError(f"no skill {name!r}")
|
|
167
|
+
|
|
168
|
+
def server_info(self, policy, *, tools: int, viewer: bool) -> dict:
|
|
169
|
+
"""What `server_info` answers inside a conversation: the product's
|
|
170
|
+
version, where its data is, the permissions (`policy.describe()`),
|
|
171
|
+
how many tools the conversation has, the skills, the licence."""
|
|
172
|
+
return {"policy": policy.describe(), "n_tools": tools, "viewer": viewer,
|
|
173
|
+
"skills": [s.get("name") for s in self.skills()]}
|
|
174
|
+
|
|
175
|
+
def serialize(self, value) -> Any:
|
|
176
|
+
"""`value` as JSON-able data (the harness bounds and offloads it)."""
|
|
177
|
+
return _serialize.clean(value)
|
|
178
|
+
|
|
179
|
+
def split_images(self, result) -> tuple:
|
|
180
|
+
"""(result without images, [(bytes, format)])."""
|
|
181
|
+
return split_images(result)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Prefix fingerprints and the cache-hit monitor.
|
|
2
|
+
|
|
3
|
+
A prompt cache pays only while the prefix is byte-identical, and a silent
|
|
4
|
+
change (a timestamp, an unsorted dict, a reordered tool list) multiplies the
|
|
5
|
+
cost of every later call without failing anything. So every call is checked:
|
|
6
|
+
from a worker's second call on, the provider must report reading at least
|
|
7
|
+
the prefix from cache. A miss is logged with the fingerprint, counted in the
|
|
8
|
+
trace, and shown by the product's trace report (`TraceStore.cache_report`).
|
|
9
|
+
The monitor logs through the `log` it is given -- the product's harness
|
|
10
|
+
logger -- so a product's log configuration sees its own misses.
|
|
11
|
+
|
|
12
|
+
The expected size is an estimate (characters / 3.5), and tool schemas and
|
|
13
|
+
prose tokenize denser than that, while providers count cached reads in
|
|
14
|
+
blocks (128 tokens at OpenAI-style caches). So the estimate is only a rough
|
|
15
|
+
first bar; once the provider has reported reading the prefix on a worker's
|
|
16
|
+
first call -- which reads only the system prompt another worker wrote -- that
|
|
17
|
+
size is the bar. A worker's later calls also read its own earlier turns (the
|
|
18
|
+
rolling breakpoints, wire.with_breakpoints), so their reads and every write
|
|
19
|
+
are prefix plus history and never set the bar. A model whose provider has
|
|
20
|
+
never reported caching anything for a prefix gets the verdict `uncached`:
|
|
21
|
+
nothing changed in the prefix, and a warning per call would say nothing new.
|
|
22
|
+
|
|
23
|
+
Some engines (vLLM / SGLang behind Chat Completions) report the chat
|
|
24
|
+
template's first few tokens as cached on every call, even a brand-new prompt:
|
|
25
|
+
a read or write of TEMPLATE_TOKENS or fewer is that, not a cached prefix. And
|
|
26
|
+
a best-effort cache (SayGM's TEE models hit or miss with their own load) can
|
|
27
|
+
miss call after call on a prefix that never changed: each prefix warns once,
|
|
28
|
+
and the rest are counted in the trace.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import logging
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
from spatialagents.wire import Usage, canonical
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("spatialagents")
|
|
40
|
+
|
|
41
|
+
CHARS_PER_TOKEN = 3.5
|
|
42
|
+
#: Share of the expected prefix a warm call must read from cache.
|
|
43
|
+
HIT_SHARE = 0.9
|
|
44
|
+
#: How far below the characters-based estimate a prefix's real token count may
|
|
45
|
+
#: be, before any provider-reported size is known for it.
|
|
46
|
+
ESTIMATE_SLACK = 0.7
|
|
47
|
+
#: Cached tokens at or under this are the chat template's opening, not a prefix.
|
|
48
|
+
TEMPLATE_TOKENS = 32
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fingerprint(system: list) -> str:
|
|
52
|
+
return hashlib.sha256(canonical(system).encode("utf-8")).hexdigest()[:16]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def expected_tokens(system: list) -> int:
|
|
56
|
+
return int(len(canonical(system)) / CHARS_PER_TOKEN)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CacheMonitor:
|
|
61
|
+
"""Verdicts per call: `cold` (first call of a worker, may write), `hit`,
|
|
62
|
+
`miss` (a warm call that did not read the prefix), `uncached` (a warm call
|
|
63
|
+
on a prefix the provider has never reported caching at all)."""
|
|
64
|
+
|
|
65
|
+
seen: dict = field(default_factory=dict)
|
|
66
|
+
counts: dict = field(default_factory=lambda: {"cold": 0, "hit": 0, "miss": 0, "uncached": 0})
|
|
67
|
+
usage: Usage = field(default_factory=Usage)
|
|
68
|
+
#: The prefix's real cached size per prefix, from what a worker's first
|
|
69
|
+
#: call read: it replaces the estimate as the bar.
|
|
70
|
+
cached: dict = field(default_factory=dict)
|
|
71
|
+
#: The prefixes the provider has reported reading or writing anything for.
|
|
72
|
+
caching: dict = field(default_factory=dict)
|
|
73
|
+
#: The prefixes a miss has already been logged for.
|
|
74
|
+
warned: set = field(default_factory=set)
|
|
75
|
+
#: Where verdicts worth a line are logged: the product's harness logger, so
|
|
76
|
+
#: its log configuration (and its tests) see them.
|
|
77
|
+
log: logging.Logger = field(default=log, repr=False, compare=False)
|
|
78
|
+
|
|
79
|
+
def observe(self, prefix_fp: str, prefix_tokens: int, usage: Usage, *, warm: bool) -> str:
|
|
80
|
+
for name in Usage.__dataclass_fields__:
|
|
81
|
+
setattr(self.usage, name, getattr(self.usage, name) + getattr(usage, name))
|
|
82
|
+
read = usage.cache_read if usage.cache_read > TEMPLATE_TOKENS else 0
|
|
83
|
+
wrote = usage.cache_write if usage.cache_write > TEMPLATE_TOKENS else 0
|
|
84
|
+
known = self.seen.get(prefix_fp, False)
|
|
85
|
+
self.seen[prefix_fp] = True
|
|
86
|
+
reported = self.cached.get(prefix_fp, 0)
|
|
87
|
+
if reported:
|
|
88
|
+
bar = HIT_SHARE * min(reported, prefix_tokens)
|
|
89
|
+
else:
|
|
90
|
+
bar = HIT_SHARE * ESTIMATE_SLACK * prefix_tokens
|
|
91
|
+
bar = min(bar, HIT_SHARE * max(1, usage.input_total))
|
|
92
|
+
if not warm and not known:
|
|
93
|
+
verdict = "cold"
|
|
94
|
+
elif read and read >= bar:
|
|
95
|
+
verdict = "hit"
|
|
96
|
+
elif not self.caching.get(prefix_fp) and not read and not wrote:
|
|
97
|
+
verdict = "uncached"
|
|
98
|
+
if not self.counts["uncached"]:
|
|
99
|
+
self.log.info("prompt caching not reported on prefix %s: the provider wrote and read "
|
|
100
|
+
"nothing", prefix_fp)
|
|
101
|
+
else:
|
|
102
|
+
verdict = "miss"
|
|
103
|
+
if prefix_fp not in self.warned:
|
|
104
|
+
self.warned.add(prefix_fp)
|
|
105
|
+
self.log.warning("prompt-cache miss on prefix %s: read %d of ~%d prefix tokens "
|
|
106
|
+
"(further misses on it are counted in the trace, not logged)",
|
|
107
|
+
prefix_fp, usage.cache_read, int(reported or prefix_tokens))
|
|
108
|
+
if read or wrote:
|
|
109
|
+
self.caching[prefix_fp] = True
|
|
110
|
+
if read and not warm:
|
|
111
|
+
self.cached[prefix_fp] = max(reported, read)
|
|
112
|
+
self.counts[verdict] += 1
|
|
113
|
+
return verdict
|
|
114
|
+
|
|
115
|
+
def read_share(self) -> float:
|
|
116
|
+
total = self.usage.input_total
|
|
117
|
+
return self.usage.cache_read / total if total else 0.0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The surfaces a conversation and a plan are driven from.
|
|
2
|
+
|
|
3
|
+
http ChatApi: the HTTP + SSE contract, framework-free, over ChatService and PlanService
|
|
4
|
+
server ChatServer: the page and the API on a loopback port (stdlib only), token -> cookie
|
|
5
|
+
static/ the page: chat.js (no build step), chat.css, index.html
|
|
6
|
+
cli the terminal loop (`plexora ai chat`, `scimappro ai chat --terminal`)
|
|
7
|
+
|
|
8
|
+
A product with a web app of its own mounts `ChatApi` behind its own guards
|
|
9
|
+
(Plexora's Flask blueprint is a thin adapter over it); one without serves it
|
|
10
|
+
with `ChatServer`.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A conversation in the terminal: a plain `input()` / `print()` loop.
|
|
2
|
+
|
|
3
|
+
runner = service.runner(conversation_id)
|
|
4
|
+
loop(runner, label="plexora")
|
|
5
|
+
|
|
6
|
+
Each line the person types is one turn; its events are printed as they
|
|
7
|
+
happen -- the answer streaming as it is written, a line per tool call and
|
|
8
|
+
result (with the Undo id of a reversible write), the running credit, a pause
|
|
9
|
+
and why, sub-agents starting and finishing. A call that needs approval asks
|
|
10
|
+
`approve? [y/N]` and the answer is the person's own (`approvals.decide`, by
|
|
11
|
+
`terminal`): nothing the model says can answer it. An empty line, `exit`,
|
|
12
|
+
`quit`, end of input or Ctrl-C ends the loop and releases the conversation for
|
|
13
|
+
another process to resume.
|
|
14
|
+
|
|
15
|
+
The product's command around it starts or resumes the conversation, says what
|
|
16
|
+
it is and how to resume it, and returns the exit code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from spatialagents.approvals import decide
|
|
25
|
+
|
|
26
|
+
#: Credits are 10,000 micro-dollars (the gateway's `credit_micro`).
|
|
27
|
+
CREDIT_MICRO = 10_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def credits(micro) -> str:
|
|
31
|
+
"""`micro` (micro-dollars) as the person reads it: credits and dollars."""
|
|
32
|
+
micro = int(micro or 0)
|
|
33
|
+
return f"{micro / CREDIT_MICRO:,.1f} credits (${micro / 1_000_000:,.2f})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def loop(runner, *, read=input, write=print, label: str = "assistant", out=None) -> int:
|
|
37
|
+
"""Read lines, run turns, print their events, until the person stops.
|
|
38
|
+
|
|
39
|
+
`read(prompt)` and `write(line)` are the terminal (tests pass their own);
|
|
40
|
+
the streamed answer goes to `out` (default: `sys.stdout` as it is at the
|
|
41
|
+
time), after `label> `."""
|
|
42
|
+
stream = out or sys.stdout
|
|
43
|
+
cid = runner.conversation_id
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
text = read("you> ").strip()
|
|
47
|
+
except (EOFError, KeyboardInterrupt):
|
|
48
|
+
break
|
|
49
|
+
if not text or text.lower() in ("exit", "quit"):
|
|
50
|
+
break
|
|
51
|
+
streaming = False
|
|
52
|
+
for event in runner.turn(text):
|
|
53
|
+
kind = event.get("event")
|
|
54
|
+
who = f"[{event['agent']}] " if event.get("agent") else ""
|
|
55
|
+
if kind == "text_delta" and not event.get("agent"):
|
|
56
|
+
if not streaming:
|
|
57
|
+
stream.write(f"{label}> ")
|
|
58
|
+
streaming = True
|
|
59
|
+
stream.write(event["text"])
|
|
60
|
+
stream.flush()
|
|
61
|
+
continue
|
|
62
|
+
if streaming and kind != "text_delta":
|
|
63
|
+
stream.write("\n")
|
|
64
|
+
streaming = False
|
|
65
|
+
if kind == "tool_call":
|
|
66
|
+
write(f" {who}-> {event['tool']}")
|
|
67
|
+
elif kind == "tool_result":
|
|
68
|
+
extra = f" (undo: {event['operation_id']})" if event.get("undo") else ""
|
|
69
|
+
write(f" {who}<- {event['tool']}: {'ok' if event['ok'] else 'error'} [{event['source']}]{extra}")
|
|
70
|
+
elif kind == "approval_requested":
|
|
71
|
+
write(f" {who}{event['tool']} ({event['permission']}) wants to run with "
|
|
72
|
+
f"{json.dumps(event.get('arguments'))}")
|
|
73
|
+
answer = read(" approve? [y/N] ").strip().lower()
|
|
74
|
+
decide(runner.store, cid, event["approval_id"], answer in ("y", "yes"), by="terminal")
|
|
75
|
+
elif kind == "usage" and not event.get("agent"):
|
|
76
|
+
write(f" ({credits(event.get('total_charged_micro'))} so far)")
|
|
77
|
+
elif kind == "paused":
|
|
78
|
+
write(f" paused: {event.get('reason')} -- {event.get('resume')}")
|
|
79
|
+
elif kind == "error":
|
|
80
|
+
write(f" error: {event.get('code')}: {event.get('message')}")
|
|
81
|
+
elif kind in ("agent_started", "agent_finished"):
|
|
82
|
+
write(f" {who}{kind.replace('_', ' ')}: {event.get('brief') or event.get('summary') or ''}")
|
|
83
|
+
runner.store.release(cid)
|
|
84
|
+
return 0
|