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,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
@@ -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