co-agent-coder 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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Artifact tools: save to the repo/Drive and render in the transcript (FR-TOL-7).
|
|
2
|
+
|
|
3
|
+
Two tools, two jobs, one store:
|
|
4
|
+
|
|
5
|
+
* `save_artifact` — takes bytes the agent produced (a plot written by a cell, a
|
|
6
|
+
CSV from a script, a text report) and puts it through the §3.5 size policy. The
|
|
7
|
+
model never chooses the branch: it names a file, the store decides whether the
|
|
8
|
+
bytes are committed or pointed at by hash.
|
|
9
|
+
* `display_artifact` — renders an artifact inline in the notebook cell, so the
|
|
10
|
+
user sees the figure rather than a file path (FR-UX-4, US-3). The rendering is
|
|
11
|
+
done by an optional callback: on Colab it is `IPython.display`, on the CLI it is
|
|
12
|
+
a printed path. The loop must work with neither available, so a missing UI is a
|
|
13
|
+
normal result, not an error.
|
|
14
|
+
|
|
15
|
+
Why `save_artifact` refuses to invent content
|
|
16
|
+
---------------------------------------------
|
|
17
|
+
The tool takes a *path* to something already on disk, or explicit text. It
|
|
18
|
+
deliberately cannot fabricate bytes from a description: an agent that can invent
|
|
19
|
+
a plot it never drew is worse than one that says "the cell produced nothing".
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import binascii
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ..context.artifacts import ArtifactError, ArtifactRecord, ArtifactStore
|
|
31
|
+
from ..safety.policy import Verdict
|
|
32
|
+
from .base import ArgSpec, SafetyClass, ToolError, ToolResult
|
|
33
|
+
from .fs import _PolicyTool
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SaveArtifactTool",
|
|
37
|
+
"DisplayArtifactTool",
|
|
38
|
+
"MAX_SAVE_BYTES",
|
|
39
|
+
"RenderCallback",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Hard ceiling for one artifact. Matches the store's copy limit; anything larger
|
|
43
|
+
#: is a dataset that belongs where it already is.
|
|
44
|
+
MAX_SAVE_BYTES = 2 * 1024 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
RenderCallback = Callable[[str, str, str], None]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _ArtifactTool(_PolicyTool):
|
|
50
|
+
"""Shared plumbing: the store comes from the session, like the policy does."""
|
|
51
|
+
|
|
52
|
+
store: ArtifactStore
|
|
53
|
+
|
|
54
|
+
def __init__(self, session: Any) -> None:
|
|
55
|
+
super().__init__(session)
|
|
56
|
+
self.session = session
|
|
57
|
+
self.store = session.artifact_store
|
|
58
|
+
|
|
59
|
+
def _require_store(self) -> ArtifactStore:
|
|
60
|
+
if self.store is None: # pragma: no cover - build_registry always sets it
|
|
61
|
+
raise ToolError(
|
|
62
|
+
"no artifact store is configured for this session; artifact tools are "
|
|
63
|
+
"unavailable. Everything else still works."
|
|
64
|
+
)
|
|
65
|
+
return self.store
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SaveArtifactTool(_ArtifactTool):
|
|
69
|
+
name = "save_artifact"
|
|
70
|
+
description = (
|
|
71
|
+
"Save a produced file (plot, CSV, report, notebook output) as an artifact. "
|
|
72
|
+
"Give a `source` path for a file that already exists, or `text` for content "
|
|
73
|
+
"you have inline. Files under the commit cap are committed under "
|
|
74
|
+
"artifacts/<run>/; larger files go to Drive and are recorded by sha256. "
|
|
75
|
+
"Use this — not write_file — for anything a tool generated."
|
|
76
|
+
)
|
|
77
|
+
safety = SafetyClass.WRITE
|
|
78
|
+
args = (
|
|
79
|
+
ArgSpec("name", "string", "Artifact filename, e.g. 'loss.png' or 'bench.csv'."),
|
|
80
|
+
ArgSpec("source", "string", "Repo-relative or /tmp path of an existing file.", required=False, default=""),
|
|
81
|
+
ArgSpec("text", "string", "Inline text content, used only when `source` is absent.", required=False, default=""),
|
|
82
|
+
ArgSpec("mime", "string", "MIME type, e.g. image/png. Helps pick the extension.", required=False, default=""),
|
|
83
|
+
ArgSpec("subdir", "string", "Subdirectory inside the run dir, e.g. 'plots'.", required=False, default=""),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def run(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
source: str = "",
|
|
90
|
+
text: str = "",
|
|
91
|
+
mime: str = "",
|
|
92
|
+
subdir: str = "",
|
|
93
|
+
) -> ToolResult:
|
|
94
|
+
store = self._require_store()
|
|
95
|
+
if not source and not text:
|
|
96
|
+
raise ToolError(
|
|
97
|
+
"save_artifact needs either `source` (a file that already exists) or "
|
|
98
|
+
"`text`. It will not invent content: run the cell or script that "
|
|
99
|
+
"produces the file first."
|
|
100
|
+
)
|
|
101
|
+
if source and text:
|
|
102
|
+
raise ToolError("give `source` or `text`, not both.")
|
|
103
|
+
|
|
104
|
+
infer_mime = mime or _mime_for(name)
|
|
105
|
+
try:
|
|
106
|
+
if source:
|
|
107
|
+
path = self._artifact_source(source)
|
|
108
|
+
record = store.save_file(path, name=name, mime=infer_mime, subdir=subdir)
|
|
109
|
+
else:
|
|
110
|
+
record = store.save_text(text, name, mime=infer_mime or "text/plain", subdir=subdir)
|
|
111
|
+
except ArtifactError as exc:
|
|
112
|
+
raise ToolError(str(exc)) from exc
|
|
113
|
+
|
|
114
|
+
self.session.mark_written(store.dir / record.name)
|
|
115
|
+
return ToolResult.success(
|
|
116
|
+
self._describe(record),
|
|
117
|
+
artifact=record.path,
|
|
118
|
+
sha256=record.sha256,
|
|
119
|
+
store=record.store,
|
|
120
|
+
bytes=record.bytes,
|
|
121
|
+
committed=record.in_repo,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _artifact_source(self, source: str) -> Path:
|
|
125
|
+
"""A produced file may live in the repo or the scratch dir, nowhere else."""
|
|
126
|
+
resolved = self.session.resolve(source)
|
|
127
|
+
decision = self.session.policy.check_read(resolved)
|
|
128
|
+
if decision.verdict is Verdict.DENY:
|
|
129
|
+
raise ToolError(
|
|
130
|
+
f"cannot read {source} to save it: {decision.reason}. Write the file "
|
|
131
|
+
"inside the repo or .coagent/tmp first."
|
|
132
|
+
)
|
|
133
|
+
if not resolved.is_file():
|
|
134
|
+
raise ToolError(
|
|
135
|
+
f"{source} does not exist, so there is nothing to save. Produce the file "
|
|
136
|
+
"first (run the cell or script), then save it."
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
size = resolved.stat().st_size
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise ToolError(f"cannot stat {source}: {exc}") from exc
|
|
142
|
+
if size > MAX_SAVE_BYTES:
|
|
143
|
+
raise ToolError(
|
|
144
|
+
f"{source} is {size / 1024**3:.1f} GB, over the 2 GB artifact limit. "
|
|
145
|
+
"Keep it where it is and record the path instead."
|
|
146
|
+
)
|
|
147
|
+
if size == 0:
|
|
148
|
+
raise ToolError(
|
|
149
|
+
f"{source} is empty. An empty artifact is almost always a failed job; "
|
|
150
|
+
"check the cell or script that was meant to write it."
|
|
151
|
+
)
|
|
152
|
+
return resolved
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _describe(record: ArtifactRecord) -> str:
|
|
156
|
+
where = (
|
|
157
|
+
"committed"
|
|
158
|
+
if record.in_repo
|
|
159
|
+
else f"not committed ({record.store})"
|
|
160
|
+
)
|
|
161
|
+
body = (
|
|
162
|
+
f"saved {record.path} ({record.human_size}, sha256 {record.sha256[:12]}, {where})"
|
|
163
|
+
)
|
|
164
|
+
if record.note:
|
|
165
|
+
body += f"\n{record.note}"
|
|
166
|
+
if not record.in_repo and record.location:
|
|
167
|
+
body += f"\nstored at: {record.location}"
|
|
168
|
+
return body
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class DisplayArtifactTool(_ArtifactTool):
|
|
172
|
+
name = "display_artifact"
|
|
173
|
+
description = (
|
|
174
|
+
"Render an artifact inline in the notebook/transcript so the user can see it "
|
|
175
|
+
"(an image, HTML, or text). Use this after save_artifact when the user asked "
|
|
176
|
+
"for something visual. Passing nothing renders the whole run manifest."
|
|
177
|
+
)
|
|
178
|
+
safety = SafetyClass.UI
|
|
179
|
+
args = (
|
|
180
|
+
ArgSpec("path", "string", "Artifact path or an in-repo image path to render.", required=False, default=""),
|
|
181
|
+
ArgSpec("mime", "string", "Override the MIME type when it cannot be inferred.", required=False, default=""),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def run(self, path: str = "", mime: str = "") -> ToolResult:
|
|
185
|
+
store = self._require_store()
|
|
186
|
+
if not path:
|
|
187
|
+
return ToolResult.success(
|
|
188
|
+
store.manifest.render(),
|
|
189
|
+
shown=0,
|
|
190
|
+
manifest=str(store.manifest_path),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
target, resolved_mime = self._locate(path, mime)
|
|
194
|
+
try:
|
|
195
|
+
data = target.read_bytes()
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise ToolError(f"cannot read {path} to display it: {exc}") from exc
|
|
198
|
+
|
|
199
|
+
kind = _display_kind(resolved_mime)
|
|
200
|
+
renderer: RenderCallback | None = getattr(self.session, "render", None)
|
|
201
|
+
payload = _payload(data, kind)
|
|
202
|
+
|
|
203
|
+
if renderer is None:
|
|
204
|
+
# Headless (CLI, tests): the artifact is still on disk and referenced,
|
|
205
|
+
# which is all FR-UX-4 can promise without a notebook.
|
|
206
|
+
return ToolResult.success(
|
|
207
|
+
f"{path} ({resolved_mime or 'unknown type'}) is ready but there is no notebook "
|
|
208
|
+
"surface to render it in; open it at the path above.",
|
|
209
|
+
shown=0,
|
|
210
|
+
path=str(target),
|
|
211
|
+
mime=resolved_mime,
|
|
212
|
+
kind=kind,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
renderer(kind, payload, resolved_mime)
|
|
217
|
+
except Exception as exc: # noqa: BLE001 - a UI failure must not end a step
|
|
218
|
+
return ToolResult.success(
|
|
219
|
+
f"{path} was saved but the notebook could not render it ({exc}). "
|
|
220
|
+
"The file is still available at that path.",
|
|
221
|
+
shown=0,
|
|
222
|
+
path=str(target),
|
|
223
|
+
mime=resolved_mime,
|
|
224
|
+
error=str(exc),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return ToolResult.success(
|
|
228
|
+
f"rendered {path} ({resolved_mime or kind}) in the transcript",
|
|
229
|
+
shown=1,
|
|
230
|
+
path=str(target),
|
|
231
|
+
mime=resolved_mime,
|
|
232
|
+
kind=kind,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _locate(self, path: str, mime: str) -> tuple[Path, str]:
|
|
236
|
+
"""Find the artifact's bytes: in the run dir, or anywhere readable in jail."""
|
|
237
|
+
store = self._require_store()
|
|
238
|
+
candidate = Path(path)
|
|
239
|
+
resolved = candidate if candidate.is_absolute() else self.session.repo_root / candidate
|
|
240
|
+
resolved = resolved.resolve()
|
|
241
|
+
|
|
242
|
+
# A manifest `path` is repo-relative; a Drive artifact's bytes live at
|
|
243
|
+
# `location`. Prefer real bytes wherever they are.
|
|
244
|
+
for record in store.manifest.artifacts:
|
|
245
|
+
if path in {record.path, record.name}:
|
|
246
|
+
if record.location:
|
|
247
|
+
location = Path(record.location)
|
|
248
|
+
if location.is_file():
|
|
249
|
+
return location, mime or record.mime
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
decision = self.session.policy.check_read(resolved)
|
|
253
|
+
if decision.verdict is Verdict.DENY:
|
|
254
|
+
raise ToolError(f"cannot display {path}: {decision.reason}")
|
|
255
|
+
if not resolved.is_file():
|
|
256
|
+
raise ToolError(
|
|
257
|
+
f"{path} is not a saved artifact and does not exist. Use save_artifact "
|
|
258
|
+
"first, or pass a path inside the run's artifact directory."
|
|
259
|
+
)
|
|
260
|
+
return resolved, mime or _mime_for(resolved.name)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _display_kind(mime: str) -> str:
|
|
264
|
+
if mime.startswith("image/"):
|
|
265
|
+
return "image"
|
|
266
|
+
if mime == "text/html":
|
|
267
|
+
return "html"
|
|
268
|
+
return "text"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _payload(data: bytes, kind: str) -> str:
|
|
272
|
+
"""Images travel as base64 so the renderer can be written as a string only."""
|
|
273
|
+
if kind == "image":
|
|
274
|
+
return base64.b64encode(data).decode("ascii")
|
|
275
|
+
try:
|
|
276
|
+
return data.decode("utf-8")
|
|
277
|
+
except UnicodeDecodeError:
|
|
278
|
+
raise ToolError(
|
|
279
|
+
"this artifact is binary and its type is not an image; it cannot be rendered "
|
|
280
|
+
"inline. The manifest still records it."
|
|
281
|
+
) from None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
_MIME_BY_EXT = {
|
|
285
|
+
".png": "image/png",
|
|
286
|
+
".jpg": "image/jpeg",
|
|
287
|
+
".jpeg": "image/jpeg",
|
|
288
|
+
".svg": "image/svg+xml",
|
|
289
|
+
".html": "text/html",
|
|
290
|
+
".htm": "text/html",
|
|
291
|
+
".csv": "text/csv",
|
|
292
|
+
".json": "application/json",
|
|
293
|
+
".txt": "text/plain",
|
|
294
|
+
".md": "text/plain",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _mime_for(name: str) -> str:
|
|
299
|
+
return _MIME_BY_EXT.get(Path(name).suffix.lower(), "")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def decode_base64_lenient(text: str) -> bytes | None:
|
|
303
|
+
"""Decode a data payload a cell reported, tolerating whitespace/newlines."""
|
|
304
|
+
try:
|
|
305
|
+
return base64.b64decode(text, validate=False)
|
|
306
|
+
except (binascii.Error, ValueError):
|
|
307
|
+
return None
|
coagent/tools/base.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Tool registry and the contract every tool implements (PRD FR-TOL-1).
|
|
2
|
+
|
|
3
|
+
The model may only do what is registered here, with only the arguments the
|
|
4
|
+
schema declares. Unknown tool names and unknown argument keys are rejected with
|
|
5
|
+
a corrective message rather than a crash — a malformed tool call costs a turn,
|
|
6
|
+
and the cheapest recovery is to tell the model exactly what was wrong.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
|
|
16
|
+
from ..safety.policy import SafetyClass
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ArgSpec",
|
|
20
|
+
"BaseTool",
|
|
21
|
+
"ToolError",
|
|
22
|
+
"ToolRegistry",
|
|
23
|
+
"ToolResult",
|
|
24
|
+
"ToolSchemaError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolError(RuntimeError):
|
|
29
|
+
"""A tool ran but failed. The message is fed back to the model verbatim."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolSchemaError(ToolError):
|
|
33
|
+
"""The call was malformed. Rejected before the tool body runs (FR-TOL-1)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ArgSpec:
|
|
38
|
+
"""One declared argument."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
type: str # "string" | "integer" | "number" | "boolean" | "array" | "object"
|
|
42
|
+
description: str
|
|
43
|
+
required: bool = True
|
|
44
|
+
default: Any = None
|
|
45
|
+
enum: tuple[str, ...] | None = None
|
|
46
|
+
|
|
47
|
+
def json_schema(self) -> dict[str, Any]:
|
|
48
|
+
schema: dict[str, Any] = {"type": self.type, "description": self.description}
|
|
49
|
+
if self.enum:
|
|
50
|
+
schema["enum"] = list(self.enum)
|
|
51
|
+
if self.default is not None:
|
|
52
|
+
schema["default"] = self.default
|
|
53
|
+
return schema
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class ToolResult:
|
|
58
|
+
"""What a tool returns to the loop."""
|
|
59
|
+
|
|
60
|
+
ok: bool
|
|
61
|
+
content: str
|
|
62
|
+
tool: str = ""
|
|
63
|
+
# Structured extras for the UI (diffs, paths, images) — never sent to the model.
|
|
64
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
truncated: bool = False
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def success(cls, content: str, **meta: Any) -> ToolResult:
|
|
69
|
+
return cls(ok=True, content=content, meta=meta)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def failure(cls, content: str, **meta: Any) -> ToolResult:
|
|
73
|
+
return cls(ok=False, content=content, meta=meta)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BaseTool(ABC):
|
|
77
|
+
"""One registered action."""
|
|
78
|
+
|
|
79
|
+
name: ClassVar[str] = ""
|
|
80
|
+
description: ClassVar[str] = ""
|
|
81
|
+
safety: ClassVar[SafetyClass] = SafetyClass.READ
|
|
82
|
+
args: ClassVar[tuple[ArgSpec, ...]] = ()
|
|
83
|
+
|
|
84
|
+
def __init__(self, ctx: Any = None) -> None:
|
|
85
|
+
self.ctx = ctx
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------- schema
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def parameters_schema(cls) -> dict[str, Any]:
|
|
91
|
+
props = {a.name: a.json_schema() for a in cls.args}
|
|
92
|
+
required = [a.name for a in cls.args if a.required and a.default is None]
|
|
93
|
+
return {"type": "object", "properties": props, "required": required}
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def tool_schema(cls) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"name": cls.name,
|
|
99
|
+
"description": cls.description,
|
|
100
|
+
"safety": cls.safety.value,
|
|
101
|
+
"parameters": cls.parameters_schema(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def validate_args(cls, args: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
"""Reject unknown keys and missing required args with a corrective message."""
|
|
107
|
+
if not isinstance(args, dict):
|
|
108
|
+
raise ToolSchemaError(
|
|
109
|
+
f"arguments for {cls.name} must be an object, got {type(args).__name__}"
|
|
110
|
+
)
|
|
111
|
+
known = {a.name for a in cls.args}
|
|
112
|
+
unknown = set(args) - known
|
|
113
|
+
if unknown:
|
|
114
|
+
raise ToolSchemaError(
|
|
115
|
+
f"unknown argument(s) for {cls.name}: {', '.join(sorted(unknown))}. "
|
|
116
|
+
f"Valid arguments are: {', '.join(sorted(known)) or '(none)'}."
|
|
117
|
+
)
|
|
118
|
+
cleaned: dict[str, Any] = {}
|
|
119
|
+
for spec in cls.args:
|
|
120
|
+
if spec.name not in args:
|
|
121
|
+
if spec.required and spec.default is None:
|
|
122
|
+
raise ToolSchemaError(
|
|
123
|
+
f"missing required argument {spec.name!r} for {cls.name}."
|
|
124
|
+
)
|
|
125
|
+
cleaned[spec.name] = spec.default
|
|
126
|
+
continue
|
|
127
|
+
cleaned[spec.name] = _coerce(cls.name, spec, args[spec.name])
|
|
128
|
+
return cleaned
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------- body
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def run(self, **kwargs: Any) -> ToolResult:
|
|
134
|
+
"""Execute the tool. Raise `ToolError` for an expected failure."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _coerce(tool: str, spec: ArgSpec, value: Any) -> Any:
|
|
138
|
+
"""Check the declared type, correcting the common JSON mistakes."""
|
|
139
|
+
want = spec.type
|
|
140
|
+
if want == "string":
|
|
141
|
+
if not isinstance(value, str):
|
|
142
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be a string, got {type(value).__name__}")
|
|
143
|
+
if spec.enum and value not in spec.enum:
|
|
144
|
+
raise ToolSchemaError(
|
|
145
|
+
f"{tool}.{spec.name} must be one of {list(spec.enum)}, got {value!r}"
|
|
146
|
+
)
|
|
147
|
+
return value
|
|
148
|
+
if want == "boolean":
|
|
149
|
+
if isinstance(value, bool):
|
|
150
|
+
return value
|
|
151
|
+
if isinstance(value, str) and value.lower() in {"true", "false"}:
|
|
152
|
+
return value.lower() == "true"
|
|
153
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be a boolean")
|
|
154
|
+
if want == "integer":
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be an integer")
|
|
157
|
+
if isinstance(value, int):
|
|
158
|
+
return value
|
|
159
|
+
if isinstance(value, str) and value.strip().lstrip("-").isdigit():
|
|
160
|
+
return int(value)
|
|
161
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be an integer")
|
|
162
|
+
if want == "number":
|
|
163
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
164
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be a number")
|
|
165
|
+
return float(value)
|
|
166
|
+
if want == "array":
|
|
167
|
+
if not isinstance(value, list):
|
|
168
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be an array")
|
|
169
|
+
return value
|
|
170
|
+
if want == "object":
|
|
171
|
+
if not isinstance(value, dict):
|
|
172
|
+
raise ToolSchemaError(f"{tool}.{spec.name} must be an object")
|
|
173
|
+
return value
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ToolRegistry:
|
|
178
|
+
"""The set of tools available in the current phase."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, tools: list[BaseTool] | None = None) -> None:
|
|
181
|
+
self._tools: dict[str, BaseTool] = {}
|
|
182
|
+
for tool in tools or []:
|
|
183
|
+
self.register(tool)
|
|
184
|
+
|
|
185
|
+
def register(self, tool: BaseTool) -> None:
|
|
186
|
+
if not tool.name:
|
|
187
|
+
raise ValueError(f"{type(tool).__name__} has no name")
|
|
188
|
+
if tool.name in self._tools:
|
|
189
|
+
raise ValueError(f"duplicate tool name {tool.name!r}")
|
|
190
|
+
self._tools[tool.name] = tool
|
|
191
|
+
|
|
192
|
+
def get(self, name: str) -> BaseTool:
|
|
193
|
+
try:
|
|
194
|
+
return self._tools[name]
|
|
195
|
+
except KeyError:
|
|
196
|
+
raise ToolSchemaError(
|
|
197
|
+
f"unknown tool {name!r}. Available tools: {', '.join(sorted(self._tools)) or '(none)'}."
|
|
198
|
+
) from None
|
|
199
|
+
|
|
200
|
+
def __contains__(self, name: object) -> bool:
|
|
201
|
+
return name in self._tools
|
|
202
|
+
|
|
203
|
+
def __len__(self) -> int:
|
|
204
|
+
return len(self._tools)
|
|
205
|
+
|
|
206
|
+
def names(self) -> list[str]:
|
|
207
|
+
return sorted(self._tools)
|
|
208
|
+
|
|
209
|
+
def schemas(self, *, safety: set[SafetyClass] | None = None) -> list[dict[str, Any]]:
|
|
210
|
+
"""JSON tool schemas, optionally filtered to a phase's allowed classes."""
|
|
211
|
+
return [
|
|
212
|
+
t.tool_schema()
|
|
213
|
+
for t in self._tools.values()
|
|
214
|
+
if safety is None or t.safety in safety
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def schemas_json(self, *, safety: set[SafetyClass] | None = None) -> str:
|
|
218
|
+
return json.dumps(self.schemas(safety=safety), indent=None, separators=(",", ":"))
|
|
219
|
+
|
|
220
|
+
def call(self, name: str, args: dict[str, Any]) -> ToolResult:
|
|
221
|
+
"""Validate then dispatch. Schema errors never reach the tool body."""
|
|
222
|
+
tool = self.get(name)
|
|
223
|
+
cleaned = tool.validate_args(args)
|
|
224
|
+
result = tool.run(**cleaned)
|
|
225
|
+
result.tool = name
|
|
226
|
+
return result
|