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