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,144 @@
1
+ """Tool-result caching (harness Layer 3): a read already answered is not run again.
2
+
3
+ A product's reads are idempotent for a given revision of what they read, and
4
+ its renders are deterministic, so `ToolResultCache.get_or_call` keys every
5
+ cacheable call on
6
+
7
+ (capability, canonical(arguments), revision, product version)
8
+
9
+ and hands back the stored answer instead of recomputing it -- across turns,
10
+ sub-agents, retries and conversations. The same bytes sent to the model again
11
+ also read from the provider's prompt cache.
12
+
13
+ What may be cached is the backend's decision (`ToolBackend.cacheable`), made
14
+ per call, and the caller asks the cache only for those: a write never, a call
15
+ that returns cell-level data never (it is not copied onto disk a second time),
16
+ viewer state never (what a tab shows changes without a receipt). A failed call
17
+ is never stored, whoever asked.
18
+
19
+ Invalidation is the revision (`ToolBackend.revision_token`), which every
20
+ receipted write moves on. Edits a person makes outside the harness may leave
21
+ no receipt, so an entry also expires after `max_age_s` (15 minutes by
22
+ default).
23
+
24
+ Entries are content-addressed files, `<root>/<k[:2]>/<key>.json`, images
25
+ inline as base64, under the root the product gives (Plexora's
26
+ `<data_root>/.agent/ai/toolcache/`, SCIMAP Pro's
27
+ `<configDir>/ai-cache/toolcache/`). The product's version is part of every
28
+ key, so a release never reads its predecessor's answers. A hit is reported as
29
+ `source: cache` to the caller, which records it in the trace.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import threading
39
+ import time
40
+ from pathlib import Path
41
+
42
+ from spatialagents.wire import canonical
43
+
44
+ MAX_AGE_S = 15 * 60
45
+ MAX_ENTRY_BYTES = 24 * 1024 * 1024
46
+
47
+
48
+ def _encode(value):
49
+ if isinstance(value, (bytes, bytearray)):
50
+ return {"__b64__": base64.b64encode(bytes(value)).decode("ascii")}
51
+ if isinstance(value, dict):
52
+ return {k: _encode(v) for k, v in value.items()}
53
+ if isinstance(value, (list, tuple)):
54
+ return [_encode(v) for v in value]
55
+ return value
56
+
57
+
58
+ def _decode(value):
59
+ if isinstance(value, dict):
60
+ if set(value) == {"__b64__"}:
61
+ return base64.b64decode(value["__b64__"])
62
+ return {k: _decode(v) for k, v in value.items()}
63
+ if isinstance(value, list):
64
+ return [_decode(v) for v in value]
65
+ return value
66
+
67
+
68
+ class ToolResultCache:
69
+ def __init__(self, root: Path | str, *, version: str, max_age_s: float | None = MAX_AGE_S):
70
+ self.root = Path(root)
71
+ self.max_age_s = max_age_s
72
+ self.version = str(version)
73
+ self.hits = 0
74
+ self.misses = 0
75
+ self._lock = threading.Lock()
76
+
77
+ def key(self, capability: str, arguments: dict, revision: str) -> str:
78
+ material = canonical({"capability": capability, "arguments": arguments or {},
79
+ "revision": str(revision), "version": self.version})
80
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:40]
81
+
82
+ def _path(self, key: str) -> Path:
83
+ return self.root / key[:2] / f"{key}.json"
84
+
85
+ def get(self, key: str):
86
+ path = self._path(key)
87
+ try:
88
+ if self.max_age_s is not None and time.time() - path.stat().st_mtime > self.max_age_s:
89
+ return None
90
+ return _decode(json.loads(path.read_text(encoding="utf-8")))
91
+ except (OSError, ValueError):
92
+ return None
93
+
94
+ def put(self, key: str, outcome: dict) -> bool:
95
+ try:
96
+ text = json.dumps(_encode(outcome), separators=(",", ":"), ensure_ascii=False)
97
+ except (TypeError, ValueError):
98
+ return False
99
+ if len(text) > MAX_ENTRY_BYTES:
100
+ return False
101
+ path = self._path(key)
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
104
+ tmp.write_text(text, encoding="utf-8")
105
+ try:
106
+ os.replace(tmp, path)
107
+ except PermissionError:
108
+ tmp.unlink(missing_ok=True)
109
+ return False
110
+ return True
111
+
112
+ def get_or_call(self, capability: str, arguments: dict, revision: str, call):
113
+ """`(outcome, source)`: the stored answer and `"cache"`, or `call()`'s and
114
+ `"live"` (stored when it succeeded). `capability` is the name the call
115
+ is keyed by (the backend's capability name for the tool); `call` takes
116
+ no arguments and returns a `ToolBackend.invoke` outcome. The caller
117
+ has already asked the backend whether this call may be cached."""
118
+ key = self.key(capability, arguments, revision)
119
+ stored = self.get(key)
120
+ if stored is not None:
121
+ with self._lock:
122
+ self.hits += 1
123
+ return stored, "cache"
124
+ outcome = call()
125
+ with self._lock:
126
+ self.misses += 1
127
+ if isinstance(outcome, dict) and outcome.get("ok"):
128
+ self.put(key, outcome)
129
+ return outcome, "live"
130
+
131
+ def sweep(self, now: float | None = None) -> int:
132
+ """Delete expired entries; returns how many."""
133
+ now = now or time.time()
134
+ removed = 0
135
+ if not self.root.is_dir() or self.max_age_s is None:
136
+ return 0
137
+ for path in self.root.glob("*/*.json"):
138
+ try:
139
+ if now - path.stat().st_mtime > self.max_age_s:
140
+ path.unlink()
141
+ removed += 1
142
+ except OSError:
143
+ continue
144
+ return removed
spatialagents/tools.py ADDED
@@ -0,0 +1,460 @@
1
+ """The conversational agent's tools: a product's tools, deferred.
2
+
3
+ `ToolAdapter` turns a `ToolBackend`'s catalog into what a model is given, and
4
+ runs what it calls.
5
+
6
+ **Frozen at conversation start.** The catalog is every tool the policy could
7
+ let through (`ToolBackend.catalog`: egress allowed; writes only when the
8
+ policy allows writes; source-file writes and deletes included, because they
9
+ pause for the user's approval rather than being refused), minus any name a
10
+ local tool already has. Sorted by tool name, so the bytes are the same for
11
+ every conversation of one build and policy.
12
+
13
+ **Deferred schemas.** The model is not sent ~100 full tool schemas. The
14
+ system prompt lists each catalog tool's name and one-line purpose; the
15
+ `tools` array holds only the local tools (`load_tool`, `call_tool`,
16
+ `list_skills`, `read_skill`, `read_artifact`, `server_info`, the sub-agent and
17
+ board tools) and NEVER changes during a conversation. `load_tool` returns the
18
+ full definitions it is asked for as its result, in the history; the model then
19
+ runs a catalog tool through `call_tool(name, arguments)`. Providers cache
20
+ tools first, then the system prompt, then the messages, so a tools array that
21
+ grew on every load would make the next call rewrite the whole cached prefix
22
+ and history; a fixed one keeps every call reading them from cache. Arguments
23
+ are validated by the backend exactly as for a native tool call, and an
24
+ approval is asked for the tool `call_tool` names, never bypassed through it.
25
+ Two local tools describe the product (`list_skills`, `server_info`): their
26
+ descriptions are the product's (`ChatProfile.tool_descriptions`).
27
+
28
+ **Executing.** A catalog tool runs through `ToolBackend.invoke`, through the
29
+ `ToolResultCache` when the backend says the call is cacheable (keyed on its
30
+ `revision_token`); images go back as image blocks (`split_images`), text is
31
+ compact JSON (`serialize`, bounded at the backend's `max_result_chars`), and a
32
+ result above the offload threshold becomes a stub the model reads slices of
33
+ with `read_artifact` (`offload.py`). A reversible write reports its
34
+ operation_id so a panel can offer Undo.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import copy
40
+ import json
41
+ import threading
42
+ import time
43
+ from dataclasses import dataclass, field
44
+
45
+ from spatialagents import offload as offloading
46
+ from spatialagents import serialize
47
+ from spatialagents.policy import NEEDS_APPROVAL
48
+ from spatialagents.wire import canonical, image_block, text_block
49
+
50
+ #: The Worker accepts at most 64 tool definitions per request.
51
+ MAX_TOOLS = 64
52
+ #: Images one tool result may carry to the model.
53
+ MAX_RESULT_IMAGES = 4
54
+
55
+ LOAD_TOOL = "load_tool"
56
+ CALL_TOOL = "call_tool"
57
+ SPAWN_AGENTS = "spawn_agents"
58
+ AWAIT_AGENTS = "await_agents"
59
+ READ_BOARD = "read_board"
60
+ POST_BOARD = "post_board"
61
+ LIST_SKILLS = "list_skills"
62
+ READ_SKILL = "read_skill"
63
+ READ_ARTIFACT = "read_artifact"
64
+ SERVER_INFO = "server_info"
65
+
66
+ #: Local tools a sub-agent always has (it never spawns or loads beyond its brief).
67
+ SUBAGENT_LOCAL = (LOAD_TOOL, CALL_TOOL, LIST_SKILLS, READ_SKILL, READ_ARTIFACT, SERVER_INFO, READ_BOARD,
68
+ POST_BOARD)
69
+
70
+ #: What the two product-describing local tools say when the product gives no words of its own.
71
+ DEFAULT_DESCRIPTIONS = {
72
+ LIST_SKILLS: "The runtime skills: name, title and when to use each.",
73
+ SERVER_INFO: "What this application is: version, data directory, the permissions this conversation "
74
+ "has, the skills and the licence.",
75
+ }
76
+
77
+
78
+ def _local_definitions(descriptions: dict | None = None) -> list:
79
+ said = {**DEFAULT_DESCRIPTIONS, **(descriptions or {})}
80
+ defs = [
81
+ {"name": LOAD_TOOL,
82
+ "description": "Get the full definitions (description and input_schema) of catalog tools, so you "
83
+ "can call them with call_tool. Pass every name you expect to need.",
84
+ "input_schema": {"type": "object", "properties": {
85
+ "names": {"type": "array", "items": {"type": "string"},
86
+ "description": "Tool names from the catalog."}}, "required": ["names"]}},
87
+ {"name": CALL_TOOL,
88
+ "description": "Run one catalog tool. `arguments` must match the input_schema load_tool returned "
89
+ "for it. Several call_tool calls in one turn run in order.",
90
+ "input_schema": {"type": "object", "properties": {
91
+ "name": {"type": "string", "description": "A tool name from the catalog."},
92
+ "arguments": {"type": "object", "description": "The tool's arguments."}},
93
+ "required": ["name"]}},
94
+ {"name": LIST_SKILLS,
95
+ "description": said[LIST_SKILLS],
96
+ "input_schema": {"type": "object", "properties": {}}},
97
+ {"name": READ_SKILL,
98
+ "description": "Read one skill (a SKILL.md) before doing that kind of work.",
99
+ "input_schema": {"type": "object", "properties": {"name": {"type": "string"}},
100
+ "required": ["name"]}},
101
+ {"name": SERVER_INFO,
102
+ "description": said[SERVER_INFO],
103
+ "input_schema": {"type": "object", "properties": {}}},
104
+ {"name": READ_ARTIFACT,
105
+ "description": "Read part of a large tool result that was offloaded (it came back as a stub "
106
+ "with an artifact_id): a character range, or the lines matching a query.",
107
+ "input_schema": {"type": "object", "properties": {
108
+ "artifact_id": {"type": "string"},
109
+ "start": {"type": "integer", "description": "First character (default 0)."},
110
+ "end": {"type": "integer", "description": "One past the last character."},
111
+ "query": {"type": "string", "description": "Return the lines containing this text."}},
112
+ "required": ["artifact_id"]}},
113
+ {"name": SPAWN_AGENTS,
114
+ "description": "Run sub-agents for independent pieces of work, in parallel where their "
115
+ "dependencies allow. Each gets a brief and returns a short typed summary. "
116
+ "wait=false returns their ids at once; await_agents collects the summaries.",
117
+ "input_schema": {"type": "object", "properties": {
118
+ "agents": {"type": "array", "items": {"type": "object", "properties": {
119
+ "id": {"type": "string", "description": "Optional id other agents can depend on."},
120
+ "role": {"type": "string", "description": "A short role, e.g. image_reader."},
121
+ "brief": {"type": "string", "description": "Exactly what this agent must do."},
122
+ "inputs": {"type": "object", "description": "Small JSON inputs (names, ids)."},
123
+ "depends_on": {"type": "array", "items": {"type": "string"}},
124
+ "tools": {"type": "array", "items": {"type": "string"},
125
+ "description": "Catalog tools it may call (default: the read-only ones)."}},
126
+ "required": ["role", "brief"]}},
127
+ "wait": {"type": "boolean", "description": "Wait for every summary (default true)."}},
128
+ "required": ["agents"]}},
129
+ {"name": AWAIT_AGENTS,
130
+ "description": "Wait for sub-agents started with wait=false and return their summaries.",
131
+ "input_schema": {"type": "object", "properties": {
132
+ "ids": {"type": "array", "items": {"type": "string"}},
133
+ "timeout_s": {"type": "number", "description": "At most this long (default 120)."}},
134
+ "required": ["ids"]}},
135
+ {"name": READ_BOARD,
136
+ "description": "Read facts the agents of this conversation posted. A key ending in * reads "
137
+ "every key with that prefix.",
138
+ "input_schema": {"type": "object", "properties": {
139
+ "keys": {"type": "array", "items": {"type": "string"}}}, "required": ["keys"]}},
140
+ {"name": POST_BOARD,
141
+ "description": "Post a small fact for the other agents of this conversation to read.",
142
+ "input_schema": {"type": "object", "properties": {
143
+ "key": {"type": "string"}, "value": {"description": "Any small JSON value."}},
144
+ "required": ["key", "value"]}},
145
+ ]
146
+ return sorted(defs, key=lambda d: d["name"])
147
+
148
+
149
+ LOCAL_NAMES = tuple(d["name"] for d in _local_definitions())
150
+
151
+
152
+ def one_line(purpose: str, limit: int = 140) -> str:
153
+ text = " ".join(str(purpose or "").split())
154
+ for stop in (". ", "; "):
155
+ if stop in text:
156
+ text = text.split(stop, 1)[0]
157
+ break
158
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "..."
159
+
160
+
161
+ def build_catalog(backend, policy, *, viewer: bool = False) -> list:
162
+ """The tools a conversation may use under `policy`, by tool name."""
163
+ entries = []
164
+ for entry in backend.catalog(policy, viewer=viewer):
165
+ tool = str(entry["tool"])
166
+ if tool in LOCAL_NAMES:
167
+ continue
168
+ entries.append({"tool": tool, "capability": entry.get("capability") or tool,
169
+ "permission": entry["permission"], "purpose": one_line(entry.get("purpose") or "")})
170
+ return sorted(entries, key=lambda e: e["tool"])
171
+
172
+
173
+ def catalog_text(catalog: list) -> str:
174
+ marks = {"read": "", "reversible_write": " [write, undoable]",
175
+ "source_file_write": " [writes source file: needs approval]",
176
+ "destructive": " [cannot be undone: needs approval]"}
177
+ lines = ["TOOL CATALOG (load_tool gives a tool's definition; run it with call_tool)"]
178
+ lines += [f"- {e['tool']}: {e['purpose']}{marks.get(e['permission'], '')}" for e in catalog]
179
+ return "\n".join(lines)
180
+
181
+
182
+ def definition_for(backend, tool: str) -> dict:
183
+ """The backend's definition of `tool` as the model is given it: titles
184
+ stripped (they repeat the field names), an object schema."""
185
+ given = backend.definition(tool) or {}
186
+ schema = _strip_titles(copy.deepcopy(given.get("input_schema") or {}))
187
+ schema.setdefault("type", "object")
188
+ return {"name": given.get("name") or tool, "description": given.get("description") or "",
189
+ "input_schema": schema}
190
+
191
+
192
+ def _strip_titles(node):
193
+ if isinstance(node, dict):
194
+ props = node.get("properties")
195
+ out = {k: _strip_titles(v) for k, v in node.items() if k != "title" or isinstance(v, dict)}
196
+ if isinstance(props, dict):
197
+ out["properties"] = {k: _strip_titles(v) for k, v in props.items()}
198
+ return out
199
+ if isinstance(node, list):
200
+ return [_strip_titles(v) for v in node]
201
+ return node
202
+
203
+
204
+ @dataclass
205
+ class ToolOutcome:
206
+ tool_use_id: str
207
+ name: str
208
+ content: list
209
+ is_error: bool = False
210
+ source: str = "live"
211
+ capability: str | None = None
212
+ permission: str | None = None
213
+ operation_id: str | None = None
214
+ undo: bool = False
215
+ offloaded: str | None = None
216
+ images: list = field(default_factory=list)
217
+ summary: str = ""
218
+ latency_ms: int = 0
219
+ result: object = None
220
+ #: A revision guard this call moved: `{"scope", "before", "after"}` (`ToolBackend.revision_guard`).
221
+ advanced: dict | None = None
222
+
223
+ def block(self) -> dict:
224
+ block = {"type": "tool_result", "tool_use_id": self.tool_use_id, "content": self.content}
225
+ if self.is_error:
226
+ block["is_error"] = True
227
+ return block
228
+
229
+
230
+ def error_outcome(tool_use_id: str, name: str, message: str, **fields) -> ToolOutcome:
231
+ return ToolOutcome(tool_use_id, name, [text_block(message)], is_error=True, summary=message, **fields)
232
+
233
+
234
+ class ToolAdapter:
235
+ """The tools of one conversation. Thread-safe: sub-agents share it.
236
+
237
+ `offload_root` is where large results are stored (None: nothing is
238
+ offloaded, and a large result is only bounded). `descriptions` are the
239
+ product's words for its own local tools (`ChatProfile.tool_descriptions`)."""
240
+
241
+ def __init__(self, backend, *, policy, catalog: list | None = None, loaded: list | None = None,
242
+ cache=None, viewer: bool = False, offload_tokens: int = 4000, offload_root=None,
243
+ descriptions: dict | None = None):
244
+ self.backend = backend
245
+ self.policy = policy
246
+ self.catalog = list(catalog) if catalog is not None else build_catalog(backend, policy, viewer=viewer)
247
+ self.by_tool = {e["tool"]: e for e in self.catalog}
248
+ self.cache = cache
249
+ self.offload_tokens = offload_tokens
250
+ self.offload_root = offload_root
251
+ self.viewer = viewer
252
+ self.max_result_chars = int(getattr(backend, "max_result_chars", None) or serialize.MAX_RESULT_CHARS)
253
+ self.local = _local_definitions(descriptions)
254
+ self._loaded: list[dict] = []
255
+ self._lock = threading.Lock()
256
+ for name in loaded or ():
257
+ self._append(name)
258
+
259
+ # -- what the model is sent ---------------------------------------------------
260
+
261
+ @property
262
+ def loaded(self) -> list:
263
+ with self._lock:
264
+ return [d["name"] for d in self._loaded]
265
+
266
+ def definitions(self) -> list:
267
+ """The local tools, sorted: the same array for the whole conversation."""
268
+ return list(self.local)
269
+
270
+ def loaded_definitions(self) -> list:
271
+ with self._lock:
272
+ return list(self._loaded)
273
+
274
+ def catalog_text(self) -> str:
275
+ return catalog_text(self.catalog)
276
+
277
+ def _append(self, name: str) -> str:
278
+ if name in LOCAL_NAMES:
279
+ return "local"
280
+ if name not in self.by_tool:
281
+ return "unknown"
282
+ if any(d["name"] == name for d in self._loaded):
283
+ return "already"
284
+ self._loaded.append(definition_for(self.backend, name))
285
+ return "loaded"
286
+
287
+ def _definition(self, name: str) -> dict | None:
288
+ return next((d for d in self._loaded if d["name"] == name), None)
289
+
290
+ def load(self, names) -> dict:
291
+ """The definitions asked for, in the result. One already loaded is given
292
+ again: compaction may have summarised away the turn that first showed it."""
293
+ tools, unknown, local = [], [], []
294
+ with self._lock:
295
+ for name in dict.fromkeys(str(n) for n in (names or [])):
296
+ state = self._append(name)
297
+ if state == "unknown":
298
+ unknown.append(name)
299
+ elif state == "local":
300
+ local.append(name)
301
+ else:
302
+ tools.append(self._definition(name))
303
+ result: dict = {"tools": tools, "call_with": CALL_TOOL}
304
+ if local:
305
+ result["local"] = local
306
+ result["hint"] = "local tools are already in your tools list; call them directly"
307
+ if unknown:
308
+ result["unknown"] = unknown
309
+ result["hint"] = "only names in the tool catalog can be loaded"
310
+ return result
311
+
312
+ @staticmethod
313
+ def resolve(name: str, arguments: dict) -> tuple[str, dict]:
314
+ """The tool a call really runs: `call_tool`'s target, or the call itself."""
315
+ if name != CALL_TOOL:
316
+ return name, dict(arguments or {})
317
+ inner = (arguments or {}).get("arguments")
318
+ if isinstance(inner, str): # some models send the object as a JSON string
319
+ try:
320
+ inner = json.loads(inner) if inner.strip() else {}
321
+ except ValueError:
322
+ pass
323
+ return str((arguments or {}).get("name") or ""), inner if isinstance(inner, dict) else {}
324
+
325
+ # -- running a call -----------------------------------------------------------------
326
+
327
+ def describe(self, tool: str, arguments: dict | None = None) -> dict | None:
328
+ """`{"tool", "capability", "permission", "purpose"}` for a catalog tool and
329
+ these arguments (the permission is the backend's for this call), or None."""
330
+ entry = self.by_tool.get(tool)
331
+ if entry is None:
332
+ return None
333
+ return {"tool": tool, "capability": entry["capability"],
334
+ "permission": self.backend.permission(tool, dict(arguments or {})),
335
+ "purpose": self.backend.purpose(tool) or entry.get("purpose") or ""}
336
+
337
+ def needs_approval(self, tool: str, arguments: dict | None = None) -> bool:
338
+ if tool not in self.by_tool:
339
+ return False
340
+ return self.backend.permission(tool, dict(arguments or {})) in NEEDS_APPROVAL
341
+
342
+ def execute(self, tool_use_id: str, name: str, arguments: dict, *, policy=None,
343
+ allowed: set | None = None, approved: bool = False) -> ToolOutcome:
344
+ """Run one call. `approved`: the person approved this very call
345
+ (`ApprovalGate`), and `policy` already carries the flag it needs."""
346
+ started = time.monotonic()
347
+ arguments = dict(arguments or {})
348
+ if name == CALL_TOOL:
349
+ target, inner = self.resolve(name, arguments)
350
+ if not target or target == CALL_TOOL:
351
+ return error_outcome(tool_use_id, name, "call_tool needs the name of a catalog tool.",
352
+ source="local")
353
+ return self.execute(tool_use_id, target, inner, policy=policy, allowed=allowed, approved=approved)
354
+ if name in LOCAL_NAMES:
355
+ outcome = self._local(tool_use_id, name, arguments)
356
+ elif name not in self.by_tool:
357
+ outcome = error_outcome(tool_use_id, name, f"{name!r} is not a tool in this conversation's "
358
+ "catalog.", source="local")
359
+ elif allowed is not None and name not in allowed:
360
+ outcome = error_outcome(tool_use_id, name, f"{name} is not one of the tools this sub-agent "
361
+ "was given.", source="local")
362
+ else:
363
+ with self._lock:
364
+ shown = self._append(name) == "already"
365
+ outcome = self._backend(tool_use_id, name, arguments, policy or self.policy, approved)
366
+ if outcome.is_error and not shown:
367
+ # Called before load_tool showed its schema: show it now.
368
+ definition = self._definition(name)
369
+ outcome.content = outcome.content + [text_block("Definition: " + canonical(definition))]
370
+ outcome.latency_ms = int((time.monotonic() - started) * 1000)
371
+ return outcome
372
+
373
+ def _local(self, tool_use_id: str, name: str, arguments: dict) -> ToolOutcome:
374
+ try:
375
+ if name == LOAD_TOOL:
376
+ result = self.load(arguments.get("names") or ([arguments["name"]] if arguments.get("name")
377
+ else []))
378
+ elif name == LIST_SKILLS:
379
+ result = {"skills": [{k: s.get(k) for k in ("name", "title", "when")}
380
+ for s in self.backend.skills()]}
381
+ elif name == READ_SKILL:
382
+ result = {"name": arguments.get("name"),
383
+ "text": self.backend.read_skill(str(arguments.get("name")))}
384
+ elif name == SERVER_INFO:
385
+ result = self.backend.server_info(self.policy, tools=len(self.catalog), viewer=self.viewer)
386
+ elif name == READ_ARTIFACT:
387
+ if self.offload_root is None:
388
+ raise KeyError("nothing is offloaded in this conversation")
389
+ result = offloading.read_artifact(arguments.get("artifact_id", ""), start=arguments.get("start"),
390
+ end=arguments.get("end"), query=arguments.get("query"),
391
+ root=self.offload_root)
392
+ else:
393
+ return error_outcome(tool_use_id, name, f"{name} is handled by the conversation, not here.",
394
+ source="local")
395
+ except KeyError as exc:
396
+ return error_outcome(tool_use_id, name, str(exc).strip("'\""), source="local")
397
+ return self.wrap(tool_use_id, name, result, source="local")
398
+
399
+ def wrap(self, tool_use_id: str, name: str, result, *, source: str = "local", **fields) -> ToolOutcome:
400
+ """A successful result as a tool_result: bounded text, offloaded when large."""
401
+ data = result if isinstance(result, str) else self.backend.serialize(result)
402
+ text = data if isinstance(data, str) else serialize.dumps(data)
403
+ stub = None
404
+ if self.offload_root is not None and offloading.tokens(text) > self.offload_tokens:
405
+ stub = offloading.offload(data, self.offload_tokens, root=self.offload_root)
406
+ if stub is not None:
407
+ text = canonical(stub)
408
+ elif not isinstance(data, str):
409
+ text = serialize.bound(data, self.max_result_chars)
410
+ return ToolOutcome(tool_use_id, name, [text_block(text)], source=source,
411
+ offloaded=stub["artifact_id"] if stub else None,
412
+ summary=_summary(result), result=result, **fields)
413
+
414
+ def _backend(self, tool_use_id: str, name: str, arguments: dict, policy, approved: bool) -> ToolOutcome:
415
+ entry = self.by_tool[name]
416
+ permission = self.backend.permission(name, arguments)
417
+
418
+ def call():
419
+ return self.backend.invoke(name, arguments, policy=policy, approved=approved)
420
+
421
+ if self.cache is not None and not approved and self.backend.cacheable(name, arguments):
422
+ outcome, source = self.cache.get_or_call(entry["capability"], arguments,
423
+ self.backend.revision_token(name, arguments), call)
424
+ else:
425
+ outcome, source = call(), "live"
426
+ fields = {"capability": entry["capability"], "permission": permission}
427
+ if not outcome.get("ok"):
428
+ error = outcome.get("error") or {}
429
+ text = serialize.bound({"error": error, "operation_id": outcome.get("operation_id")},
430
+ self.max_result_chars)
431
+ return ToolOutcome(tool_use_id, name, [text_block(text)], is_error=True, source=source,
432
+ summary=f"{error.get('code', 'error')}: {error.get('message', '')}"[:300],
433
+ result={"error": error}, **fields)
434
+ result = outcome.get("result")
435
+ if isinstance(result, dict):
436
+ result = dict(result) # a cached answer is never mutated
437
+ result, images = self.backend.split_images(result)
438
+ op_id = None
439
+ undo = False
440
+ if permission != "read":
441
+ op_id = outcome.get("operation_id")
442
+ undo = bool(outcome.get("undo"))
443
+ wrapped = self.wrap(tool_use_id, name, result, source=source, operation_id=op_id, undo=undo,
444
+ advanced=outcome.get("advanced") or None, **fields)
445
+ if images:
446
+ shown = images[:MAX_RESULT_IMAGES]
447
+ wrapped.content = [image_block(data, fmt) for data, fmt in shown] + wrapped.content
448
+ wrapped.images = shown
449
+ return wrapped
450
+
451
+
452
+ def _summary(result) -> str:
453
+ if isinstance(result, str):
454
+ return result[:200]
455
+ if isinstance(result, dict):
456
+ keys = [k for k in result if not str(k).startswith("_")][:6]
457
+ return "{" + ", ".join(keys) + ("" if len(result) <= 6 else ", ...") + "}"
458
+ if isinstance(result, list):
459
+ return f"[{len(result)} items]"
460
+ return str(result)[:200]