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.
- deepparallel/__init__.py +3 -0
- deepparallel/agent.py +286 -0
- deepparallel/backend.py +302 -0
- deepparallel/branding.py +211 -0
- deepparallel/cli.py +569 -0
- deepparallel/config.py +158 -0
- deepparallel/fusion.py +225 -0
- deepparallel/licensing.py +108 -0
- deepparallel/registry.json +13 -0
- deepparallel/renderer.py +222 -0
- deepparallel/system_prompt.txt +4 -0
- deepparallel/tools/__init__.py +27 -0
- deepparallel/tools/codeast.py +171 -0
- deepparallel/tools/edit.py +29 -0
- deepparallel/tools/files.py +74 -0
- deepparallel/tools/registry.py +149 -0
- deepparallel/tools/sandbox.py +110 -0
- deepparallel/tools/search.py +38 -0
- deepparallel/tools/shell.py +38 -0
- deepparallel/tools/vision.py +54 -0
- deepparallel/tools/web.py +76 -0
- deepparallel-0.2.0.dist-info/METADATA +128 -0
- deepparallel-0.2.0.dist-info/RECORD +26 -0
- deepparallel-0.2.0.dist-info/WHEEL +5 -0
- deepparallel-0.2.0.dist-info/entry_points.txt +3 -0
- deepparallel-0.2.0.dist-info/top_level.txt +1 -0
deepparallel/renderer.py
ADDED
|
@@ -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()]
|