deepparallel 0.2.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,222 @@
1
+ """Terminal renderers for DeepParallel.
2
+
3
+ The agent loop is UI-agnostic: it talks to a `Renderer`. Two concrete
4
+ implementations:
5
+ - `RichRenderer`: interactive tty - inline token streaming + tool cards.
6
+ - `PlainRenderer`: pipe-friendly plain text for `run` / non-tty / CI.
7
+
8
+ `tool_start` / `tool_result` are a paired sequence around a tool call.
9
+ `confirm()` reads y/n via an injectable input function. The renderer prints
10
+ incrementally (no redrawing Live regions) so output never ghosts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import threading
17
+ import time
18
+ from abc import ABC, abstractmethod
19
+ from typing import Iterable
20
+
21
+ from rich.console import Console
22
+
23
+ from deepparallel import branding
24
+
25
+ _REVEAL_SECONDS = 0.04 # per-line delay for the animated intro (tests set 0)
26
+
27
+
28
+ class Renderer(ABC):
29
+ @abstractmethod
30
+ def welcome(
31
+ self,
32
+ backend_label: str,
33
+ *,
34
+ version: str = "",
35
+ tool_count: int = 0,
36
+ fusion_modes: tuple[str, ...] = (),
37
+ ) -> None: ...
38
+
39
+ @abstractmethod
40
+ def answer(self, text: str) -> None: ...
41
+
42
+ @abstractmethod
43
+ def answer_stream(self, chunks: Iterable[str]) -> str: ...
44
+
45
+ @abstractmethod
46
+ def reasoning(self, text: str) -> None: ...
47
+
48
+ @abstractmethod
49
+ def tool_start(self, name: str, args_preview: str) -> None: ...
50
+
51
+ @abstractmethod
52
+ def tool_result(self, ok: bool, summary: str, duration_s: float) -> None: ...
53
+
54
+ @abstractmethod
55
+ def confirm(self, action_title: str, detail: str) -> bool: ...
56
+
57
+ @abstractmethod
58
+ def error(self, msg: str) -> None: ...
59
+
60
+ @abstractmethod
61
+ def attribution_footer(self) -> None: ...
62
+
63
+
64
+ class PlainRenderer(Renderer):
65
+ """Pipe-friendly renderer. The final answer goes to stdout; tool activity
66
+ and diagnostics go to stderr, so `deepparallel run ... | pipe` stays clean."""
67
+
68
+ def __init__(self, out=None, err=None, *, assume_yes: bool = False):
69
+ self._out = out or sys.stdout
70
+ self._err = err or sys.stderr
71
+ self._assume_yes = assume_yes
72
+ self._cur: str | None = None
73
+
74
+ def _wo(self, s: str) -> None:
75
+ self._out.write(s)
76
+ self._out.flush()
77
+
78
+ def _we(self, s: str) -> None:
79
+ self._err.write(s)
80
+ self._err.flush()
81
+
82
+ def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
83
+ self._we(
84
+ f"DeepParallel v{version} - served via Crowe Logic\n"
85
+ f"{tool_count} tools - Backend: {backend_label}\n"
86
+ )
87
+
88
+ def answer(self, text: str) -> None:
89
+ self._wo(text.rstrip("\n") + "\n")
90
+
91
+ def answer_stream(self, chunks: Iterable[str]) -> str:
92
+ parts: list[str] = []
93
+ for c in chunks:
94
+ parts.append(c)
95
+ self._wo(c)
96
+ self._wo("\n")
97
+ return "".join(parts)
98
+
99
+ def reasoning(self, text: str) -> None:
100
+ self._we(f"[reasoning] {text}\n")
101
+
102
+ def tool_start(self, name: str, args_preview: str) -> None:
103
+ self._cur = name
104
+ self._we(f"> {name} {args_preview}".rstrip() + "\n")
105
+
106
+ def tool_result(self, ok: bool, summary: str, duration_s: float) -> None:
107
+ mark = "ok" if ok else "FAILED"
108
+ self._we(f" [{mark}] {self._cur} - {summary} ({duration_s:.1f}s)\n")
109
+ self._cur = None
110
+
111
+ def confirm(self, action_title: str, detail: str) -> bool:
112
+ return self._assume_yes
113
+
114
+ def error(self, msg: str) -> None:
115
+ self._we(f"error: {msg}\n")
116
+
117
+ def attribution_footer(self) -> None:
118
+ self._we("DeepParallel - served via Crowe Logic infrastructure. https://crowelogic.com\n")
119
+
120
+
121
+ class RichRenderer(Renderer):
122
+ def __init__(self, console: Console | None = None, *, input_fn=None):
123
+ self._console = console or branding.console
124
+ self._input_fn = input_fn or self._console.input
125
+ self._cur: str | None = None
126
+ self._timer_stop: threading.Event | None = None
127
+ self._timer_thread: threading.Thread | None = None
128
+
129
+ def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
130
+ animate = self._console.is_terminal and _REVEAL_SECONDS > 0
131
+ for line in branding.wordmark_lines():
132
+ self._console.print(f"[{branding.DP_ACCENT}]{line}[/]", highlight=False)
133
+ if animate:
134
+ time.sleep(_REVEAL_SECONDS)
135
+ self._console.print()
136
+ self._console.print(
137
+ branding.status_text(
138
+ version=version,
139
+ tool_count=tool_count,
140
+ fusion_modes=fusion_modes,
141
+ backend_label=backend_label,
142
+ )
143
+ )
144
+
145
+ def answer(self, text: str) -> None:
146
+ self._console.print(branding.build_transcript_markdown(self._console, text))
147
+
148
+ def answer_stream(self, chunks: Iterable[str]) -> str:
149
+ # Inline token streaming: no Live/transient panels, so it never ghosts
150
+ # in wide terminals or when tool turns interleave. The marker is printed
151
+ # only on the first VISIBLE character, so empty / whitespace-leading
152
+ # (tool-only) turns render no stray marker.
153
+ parts: list[str] = []
154
+ started = False
155
+ for c in chunks:
156
+ parts.append(c)
157
+ if started:
158
+ self._console.print(c, end="", soft_wrap=True, highlight=False, markup=False)
159
+ continue
160
+ if not "".join(parts).strip():
161
+ continue # only whitespace so far; hold the marker back
162
+ started = True
163
+ self._console.print(
164
+ f"[{branding.DP_ACCENT}]{branding.MARK}[/] ", end="", highlight=False
165
+ )
166
+ self._console.print(c.lstrip(), end="", soft_wrap=True, highlight=False, markup=False)
167
+ if started:
168
+ self._console.print()
169
+ return "".join(parts)
170
+
171
+ def reasoning(self, text: str) -> None:
172
+ self._console.print(branding.build_reasoning_panel(self._console, text))
173
+
174
+ def tool_start(self, name: str, args_preview: str) -> None:
175
+ self._cur = name
176
+ branding.render_tool_card(self._console, name, args_preview, status="running")
177
+ if self._console.is_terminal:
178
+ self._timer_stop = threading.Event()
179
+ start = time.monotonic()
180
+ fh = self._console.file
181
+
182
+ def tick() -> None:
183
+ while not self._timer_stop.wait(0.5): # type: ignore[union-attr]
184
+ fh.write(f"\r {name}... {time.monotonic() - start:.0f}s ")
185
+ fh.flush()
186
+
187
+ self._timer_thread = threading.Thread(target=tick, daemon=True)
188
+ self._timer_thread.start()
189
+
190
+ def _stop_timer(self) -> None:
191
+ if self._timer_stop is None:
192
+ return
193
+ self._timer_stop.set()
194
+ if self._timer_thread is not None:
195
+ self._timer_thread.join(timeout=1.0)
196
+ self._timer_stop = None
197
+ self._timer_thread = None
198
+ self._console.file.write("\r" + " " * 48 + "\r") # clear the timer line
199
+ self._console.file.flush()
200
+
201
+ def tool_result(self, ok: bool, summary: str, duration_s: float) -> None:
202
+ self._stop_timer()
203
+ branding.render_tool_card(
204
+ self._console,
205
+ self._cur or "",
206
+ "",
207
+ status="ok" if ok else "fail",
208
+ result=summary,
209
+ duration_ms=int(duration_s * 1000),
210
+ )
211
+ self._cur = None
212
+
213
+ def confirm(self, action_title: str, detail: str) -> bool:
214
+ branding.render_confirm_body(self._console, action_title, detail)
215
+ ans = (self._input_fn(" approve? [y/N] ") or "").strip().lower()
216
+ return ans in {"y", "yes"}
217
+
218
+ def error(self, msg: str) -> None:
219
+ branding.render_error(self._console, "error", detail=msg)
220
+
221
+ def attribution_footer(self) -> None:
222
+ branding.attribution_footer()
@@ -0,0 +1,4 @@
1
+ You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
+ Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
+
4
+ You can use tools to read, search, analyze, edit, and run code. Use them when they help; do not call them speculatively. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
@@ -0,0 +1,27 @@
1
+ """Tool package: a single registry singleton plus the registered tool modules.
2
+
3
+ `registry` and `tool` are defined before the submodule imports so the tool
4
+ modules can decorate against the singleton without a circular-import error.
5
+ """
6
+
7
+ from deepparallel.tools.registry import ToolRegistry
8
+
9
+ registry = ToolRegistry()
10
+ tool = registry.tool
11
+
12
+
13
+ def get_registry() -> ToolRegistry:
14
+ return registry
15
+
16
+
17
+ # Importing the submodules registers their tools against `registry`.
18
+ from deepparallel.tools import ( # noqa: E402,F401
19
+ codeast,
20
+ edit,
21
+ files,
22
+ sandbox,
23
+ search,
24
+ shell,
25
+ vision,
26
+ web,
27
+ )
@@ -0,0 +1,171 @@
1
+ """Multi-language, AST-aware code tools backed by tree-sitter.
2
+
3
+ Symbols are located structurally (not by text), so edits replace a whole
4
+ function/class definition by its byte span, preserving the rest of the file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from tree_sitter import Parser
13
+ from tree_sitter_language_pack import get_language
14
+
15
+ from deepparallel.tools import tool
16
+
17
+ _EXT_LANG = {
18
+ ".py": "python",
19
+ ".js": "javascript",
20
+ ".jsx": "javascript",
21
+ ".ts": "typescript",
22
+ ".tsx": "tsx",
23
+ ".go": "go",
24
+ ".rs": "rust",
25
+ ".java": "java",
26
+ ".rb": "ruby",
27
+ ".c": "c",
28
+ ".h": "c",
29
+ ".cpp": "cpp",
30
+ ".cc": "cpp",
31
+ ".hpp": "cpp",
32
+ ".cs": "csharp",
33
+ ".php": "php",
34
+ }
35
+
36
+ _DEF_KINDS = {
37
+ "python": {"function_definition", "class_definition"},
38
+ "javascript": {"function_declaration", "class_declaration", "method_definition"},
39
+ "typescript": {
40
+ "function_declaration",
41
+ "class_declaration",
42
+ "method_definition",
43
+ "interface_declaration",
44
+ },
45
+ "tsx": {
46
+ "function_declaration",
47
+ "class_declaration",
48
+ "method_definition",
49
+ "interface_declaration",
50
+ },
51
+ "go": {"function_declaration", "method_declaration", "type_declaration"},
52
+ "rust": {"function_item", "struct_item", "enum_item", "trait_item"},
53
+ "java": {
54
+ "class_declaration",
55
+ "method_declaration",
56
+ "interface_declaration",
57
+ "constructor_declaration",
58
+ },
59
+ "ruby": {"method", "class", "module"},
60
+ "c": {"function_definition", "struct_specifier"},
61
+ "cpp": {"function_definition", "class_specifier", "struct_specifier"},
62
+ "csharp": {"class_declaration", "method_declaration", "interface_declaration"},
63
+ "php": {"function_definition", "class_declaration", "method_declaration"},
64
+ }
65
+ _DEFAULT_KINDS = {
66
+ "function_definition",
67
+ "function_declaration",
68
+ "class_definition",
69
+ "class_declaration",
70
+ "method_definition",
71
+ "method_declaration",
72
+ }
73
+
74
+
75
+ def _collect(file_path: str):
76
+ """Return (error_str | None, lang, data_bytes, symbols)."""
77
+ path = Path(file_path).expanduser().resolve()
78
+ if not path.is_file():
79
+ return json.dumps({"error": f"File not found: {file_path}"}), None, b"", []
80
+ lang = _EXT_LANG.get(path.suffix.lower())
81
+ if not lang:
82
+ return json.dumps({"error": f"Unsupported file type: {path.suffix}"}), None, b"", []
83
+ data = path.read_bytes()
84
+ try:
85
+ root = Parser(get_language(lang)).parse(data).root_node
86
+ except Exception as e: # noqa: BLE001 - surface parser failure
87
+ return json.dumps({"error": f"parse failed: {e}"}), lang, data, []
88
+ kinds = _DEF_KINDS.get(lang, _DEFAULT_KINDS)
89
+ symbols: list[dict] = []
90
+
91
+ def walk(node):
92
+ for child in node.children:
93
+ if child.type in kinds:
94
+ name_node = child.child_by_field_name("name")
95
+ if name_node is not None:
96
+ symbols.append(
97
+ {
98
+ "name": data[name_node.start_byte : name_node.end_byte].decode(
99
+ "utf-8", "replace"
100
+ ),
101
+ "kind": child.type,
102
+ "start_line": child.start_point[0] + 1,
103
+ "end_line": child.end_point[0] + 1,
104
+ "start_byte": child.start_byte,
105
+ "end_byte": child.end_byte,
106
+ }
107
+ )
108
+ walk(child)
109
+
110
+ walk(root)
111
+ return None, lang, data, symbols
112
+
113
+
114
+ @tool(dangerous=False)
115
+ def ast_symbols(file_path: str) -> str:
116
+ """List the functions, classes, and methods defined in a source file.
117
+
118
+ :param file_path: Path to a source file (language inferred from extension).
119
+ """
120
+ err, lang, _data, symbols = _collect(file_path)
121
+ if err:
122
+ return err
123
+ public = [{k: s[k] for k in ("name", "kind", "start_line", "end_line")} for s in symbols]
124
+ return json.dumps({"language": lang, "symbols": public})
125
+
126
+
127
+ @tool(dangerous=False)
128
+ def ast_show_symbol(file_path: str, name: str) -> str:
129
+ """Return the full source of a named function/class/method.
130
+
131
+ :param file_path: Path to the source file.
132
+ :param name: Symbol name to show; must be unique in the file.
133
+ """
134
+ err, _lang, data, symbols = _collect(file_path)
135
+ if err:
136
+ return err
137
+ hits = [s for s in symbols if s["name"] == name]
138
+ if not hits:
139
+ return json.dumps({"error": f"symbol not found: {name}"})
140
+ if len(hits) > 1:
141
+ return json.dumps({"error": f"symbol '{name}' is ambiguous ({len(hits)} matches)"})
142
+ s = hits[0]
143
+ return json.dumps(
144
+ {
145
+ "source": data[s["start_byte"] : s["end_byte"]].decode("utf-8", "replace"),
146
+ "start_line": s["start_line"],
147
+ "end_line": s["end_line"],
148
+ }
149
+ )
150
+
151
+
152
+ @tool(dangerous=True)
153
+ def ast_replace_symbol(file_path: str, name: str, new_source: str) -> str:
154
+ """Replace the full definition of a named symbol with new source.
155
+
156
+ :param file_path: Path to the source file.
157
+ :param name: Symbol name to replace; must be unique in the file.
158
+ :param new_source: Replacement source for the entire definition.
159
+ """
160
+ err, _lang, data, symbols = _collect(file_path)
161
+ if err:
162
+ return err
163
+ hits = [s for s in symbols if s["name"] == name]
164
+ if not hits:
165
+ return json.dumps({"error": f"symbol not found: {name}"})
166
+ if len(hits) > 1:
167
+ return json.dumps({"error": f"symbol '{name}' is ambiguous ({len(hits)} matches)"})
168
+ s = hits[0]
169
+ new_bytes = data[: s["start_byte"]] + new_source.encode("utf-8") + data[s["end_byte"] :]
170
+ Path(file_path).expanduser().resolve().write_bytes(new_bytes)
171
+ return json.dumps({"success": True, "name": name, "start_line": s["start_line"]})
@@ -0,0 +1,29 @@
1
+ """Code-delivery edit tool (unique-match replacement)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from deepparallel.tools import tool
9
+
10
+
11
+ @tool(dangerous=True)
12
+ def edit_file(file_path: str, old_string: str, new_string: str) -> str:
13
+ """Replace a unique occurrence of old_string with new_string in a file.
14
+
15
+ :param file_path: Path to the file to edit.
16
+ :param old_string: Exact text to replace; must appear exactly once.
17
+ :param new_string: Replacement text.
18
+ """
19
+ path = Path(file_path).expanduser().resolve()
20
+ if not path.is_file():
21
+ return json.dumps({"error": f"File not found: {file_path}"})
22
+ text = path.read_text(encoding="utf-8")
23
+ count = text.count(old_string)
24
+ if count == 0:
25
+ return json.dumps({"error": "old_string not found"})
26
+ if count > 1:
27
+ return json.dumps({"error": "old_string must be unique; found multiple matches"})
28
+ path.write_text(text.replace(old_string, new_string), encoding="utf-8")
29
+ return json.dumps({"success": True, "path": str(path)})
@@ -0,0 +1,74 @@
1
+ """Read-only filesystem tools (write_file is added alongside edit tooling)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from deepparallel.tools import tool
10
+
11
+
12
+ @tool(dangerous=False)
13
+ def read_file(file_path: str, offset: int = 0, limit: int = 0) -> str:
14
+ """Read a file and return its contents with line numbers.
15
+
16
+ :param file_path: Path to the file to read.
17
+ :param offset: 0-based line index to start from.
18
+ :param limit: Maximum lines to return (0 = all).
19
+ """
20
+ path = Path(file_path).expanduser().resolve()
21
+ if not path.exists():
22
+ return json.dumps({"error": f"File not found: {file_path}"})
23
+ if not path.is_file():
24
+ return json.dumps({"error": f"Not a file: {file_path}"})
25
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
26
+ if offset > 0:
27
+ lines = lines[offset:]
28
+ if limit > 0:
29
+ lines = lines[:limit]
30
+ return "\n".join(f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(lines))
31
+
32
+
33
+ @tool(dangerous=False)
34
+ def list_dir(dir_path: str = ".") -> str:
35
+ """List the entries in a directory.
36
+
37
+ :param dir_path: Directory to list.
38
+ """
39
+ path = Path(dir_path).expanduser().resolve()
40
+ if not path.is_dir():
41
+ return json.dumps({"error": f"Not a directory: {dir_path}"})
42
+ entries = [{"name": c.name, "is_dir": c.is_dir()} for c in sorted(path.iterdir())]
43
+ return json.dumps({"path": str(path), "entries": entries})
44
+
45
+
46
+ @tool(dangerous=False)
47
+ def glob(dir_path: str, pattern: str) -> str:
48
+ """Find files matching a glob pattern under a directory.
49
+
50
+ :param dir_path: Root directory to search.
51
+ :param pattern: Glob pattern, for example **/*.py.
52
+ """
53
+ root = Path(dir_path).expanduser().resolve()
54
+ matches = [str(p) for p in sorted(root.glob(pattern)) if p.is_file()]
55
+ return json.dumps({"matches": matches[:1000]})
56
+
57
+
58
+ @tool(dangerous=True)
59
+ def write_file(file_path: str, content: str = "", content_b64: str = "") -> str:
60
+ """Write text to a file, creating parent directories as needed.
61
+
62
+ :param file_path: Destination path.
63
+ :param content: Text content to write.
64
+ :param content_b64: Base64-encoded content; used instead of content when
65
+ set, for code payloads that are awkward to JSON-escape.
66
+ """
67
+ path = Path(file_path).expanduser().resolve()
68
+ if content_b64:
69
+ data = base64.b64decode(content_b64).decode("utf-8", errors="replace")
70
+ else:
71
+ data = content
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(data, encoding="utf-8")
74
+ return json.dumps({"success": True, "path": str(path), "bytes": len(data.encode("utf-8"))})
@@ -0,0 +1,149 @@
1
+ """Lightweight tool registry with docstring-driven JSON-schema introspection.
2
+
3
+ Each tool is a plain function decorated with `@registry.tool(...)`. The signature
4
+ and docstring `:param name:` lines build an OpenAI-style function schema. Tools
5
+ carry a `dangerous` flag the agent uses to gate execution behind confirmation.
6
+ Pure Python (inspect) - no heavy SDK.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable, Union
15
+
16
+ _JSON_TYPES = {
17
+ int: {"type": "integer"},
18
+ float: {"type": "number"},
19
+ bool: {"type": "boolean"},
20
+ str: {"type": "string"},
21
+ dict: {"type": "object"},
22
+ list: {"type": "array"},
23
+ }
24
+
25
+
26
+ def _normalize_type(annotation) -> dict[str, Any]:
27
+ if annotation is inspect.Parameter.empty:
28
+ return {"type": "string"}
29
+ origin = getattr(annotation, "__origin__", None)
30
+ args = getattr(annotation, "__args__", None)
31
+ if origin is Union and args:
32
+ non_none = [a for a in args if a is not type(None)]
33
+ if len(non_none) == 1:
34
+ return _normalize_type(non_none[0])
35
+ return dict(_JSON_TYPES.get(annotation, {"type": "string"}))
36
+
37
+
38
+ def _extract_parameters(fn: Callable) -> dict[str, Any]:
39
+ sig = inspect.signature(fn)
40
+ doc = inspect.getdoc(fn) or ""
41
+ props: dict[str, Any] = {}
42
+ required: list[str] = []
43
+ for name, param in sig.parameters.items():
44
+ if name == "self":
45
+ continue
46
+ schema = _normalize_type(param.annotation)
47
+ if param.default is inspect.Parameter.empty:
48
+ required.append(name)
49
+ for line in doc.splitlines():
50
+ s = line.strip()
51
+ if s.startswith(f":param {name}:"):
52
+ schema["description"] = s.split(":", 2)[-1].strip()
53
+ break
54
+ props[name] = schema
55
+ return {"type": "object", "properties": props, "required": required}
56
+
57
+
58
+ def _extract_description(fn: Callable) -> str:
59
+ doc = inspect.getdoc(fn) or ""
60
+ out: list[str] = []
61
+ for line in doc.splitlines():
62
+ if line.strip().startswith(":"):
63
+ break
64
+ out.append(line)
65
+ return "\n".join(out).strip() or fn.__name__
66
+
67
+
68
+ def coerce_args(parameters: dict, args: dict) -> dict:
69
+ """Coerce string argument values to the schema's declared scalar type.
70
+
71
+ Models often emit "5" for an int param or "yes" for a bool. Bad values are
72
+ left untouched so the tool (or the model) can deal with them.
73
+ """
74
+ props = parameters.get("properties", {})
75
+ out: dict[str, Any] = {}
76
+ for key, value in args.items():
77
+ declared = props.get(key, {}).get("type")
78
+ if isinstance(value, str):
79
+ if declared == "integer":
80
+ try:
81
+ value = int(value)
82
+ except ValueError:
83
+ pass
84
+ elif declared == "number":
85
+ try:
86
+ value = float(value)
87
+ except ValueError:
88
+ pass
89
+ elif declared == "boolean":
90
+ value = value.strip().lower() in {"1", "true", "yes", "on"}
91
+ out[key] = value
92
+ return out
93
+
94
+
95
+ @dataclass
96
+ class ToolMeta:
97
+ name: str
98
+ fn: Callable
99
+ description: str
100
+ parameters: dict[str, Any]
101
+ dangerous: bool
102
+
103
+ def to_openai_schema(self) -> dict:
104
+ return {
105
+ "type": "function",
106
+ "function": {
107
+ "name": self.name,
108
+ "description": self.description,
109
+ "parameters": self.parameters,
110
+ },
111
+ }
112
+
113
+
114
+ class ToolRegistry:
115
+ def __init__(self) -> None:
116
+ self._tools: dict[str, ToolMeta] = {}
117
+
118
+ def tool(self, *, dangerous: bool = False) -> Callable:
119
+ def deco(fn: Callable) -> Callable:
120
+ self._tools[fn.__name__] = ToolMeta(
121
+ name=fn.__name__,
122
+ fn=fn,
123
+ description=_extract_description(fn),
124
+ parameters=_extract_parameters(fn),
125
+ dangerous=dangerous,
126
+ )
127
+
128
+ @functools.wraps(fn)
129
+ def wrapper(*a, **k):
130
+ return fn(*a, **k)
131
+
132
+ return wrapper
133
+
134
+ return deco
135
+
136
+ def get(self, name: str) -> ToolMeta | None:
137
+ return self._tools.get(name)
138
+
139
+ def list_all(self) -> list[ToolMeta]:
140
+ return list(self._tools.values())
141
+
142
+ def call(self, name: str, **kwargs) -> Any:
143
+ meta = self._tools.get(name)
144
+ if meta is None:
145
+ raise KeyError(name)
146
+ return meta.fn(**kwargs)
147
+
148
+ def schemas(self) -> list[dict]:
149
+ return [m.to_openai_schema() for m in self._tools.values()]