Skip to content

Commit b2413a2

Browse files
committed
add temporary default toolkit
1 parent 126dff2 commit b2413a2

File tree

4 files changed

+867
-32
lines changed

4 files changed

+867
-32
lines changed

packages/jupyter-ai/jupyter_ai/personas/base_persona.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,13 @@
2020
from ..litellm_utils import ToolCallList, StreamResult, ResolvedToolCall
2121

2222
# Import toolkits
23-
from jupyter_ai_tools.toolkits.file_system import toolkit as fs_toolkit
24-
from jupyter_ai_tools.toolkits.code_execution import toolkit as codeexec_toolkit
25-
from jupyter_ai_tools.toolkits.git import toolkit as git_toolkit
23+
from ..tools.default_toolkit import DEFAULT_TOOLKIT
2624

2725
if TYPE_CHECKING:
2826
from collections.abc import AsyncIterator
2927
from .persona_manager import PersonaManager
3028
from ..tools import Toolkit
3129

32-
DEFAULT_TOOLKITS: dict[str, Toolkit] = {
33-
"fs": fs_toolkit,
34-
"codeexec": codeexec_toolkit,
35-
"git": git_toolkit,
36-
}
37-
3830
class PersonaDefaults(BaseModel):
3931
"""
4032
Data structure that represents the default settings of a persona. Each persona
@@ -512,27 +504,19 @@ def get_tools(self, model_id: str) -> list[dict]:
512504

513505
tool_descriptions = []
514506

515-
# Get all tools from `jupyter_ai_tools` and store their object descriptions
516-
for toolkit_name, toolkit in DEFAULT_TOOLKITS.items():
517-
# TODO: make these tool permissions configurable.
518-
for tool in toolkit.get_tools():
519-
# Here, we are using a util function from LiteLLM to coerce
520-
# each `Tool` struct into a tool description dictionary expected
521-
# by LiteLLM.
522-
desc = {
523-
"type": "function",
524-
"function": function_to_dict(tool.callable),
525-
}
526-
527-
# Prepend the toolkit name to each function name, hopefully
528-
# ensuring every tool function has a unique name.
529-
# e.g. 'git_add' => 'git__git_add'
530-
#
531-
# TODO: Actually ensure this instead of hoping.
532-
desc['function']['name'] = f"{toolkit_name}__{desc['function']['name']}"
533-
tool_descriptions.append(desc)
507+
# Get all tools from the default toolkit and store their object descriptions
508+
for tool in DEFAULT_TOOLKIT.get_tools():
509+
# Here, we are using a util function from LiteLLM to coerce
510+
# each `Tool` struct into a tool description dictionary expected
511+
# by LiteLLM.
512+
desc = {
513+
"type": "function",
514+
"function": function_to_dict(tool.callable),
515+
}
516+
tool_descriptions.append(desc)
534517

535518
# Finally, return the tool descriptions
519+
self.log.info(tool_descriptions)
536520
return tool_descriptions
537521

538522

@@ -549,9 +533,9 @@ async def run_tools(self, tools: list[ResolvedToolCall]) -> list[dict]:
549533
tool_outputs: list[dict] = []
550534
for tool_call in tools:
551535
# Get tool definition from the correct toolkit
552-
toolkit_name, tool_name = tool_call.function.name.split("__")
553-
assert toolkit_name in DEFAULT_TOOLKITS
554-
tool_defn = DEFAULT_TOOLKITS[toolkit_name].get_tool_unsafe(tool_name)
536+
# TODO: validation?
537+
tool_name = tool_call.function.name
538+
tool_defn = DEFAULT_TOOLKIT.get_tool_unsafe(tool_name)
555539

556540
# Run tool and store its output
557541
output = tool_defn.callable(**tool_call.function.arguments)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tools package for Jupyter AI."""
22

33
from .models import Tool, Toolkit
4+
from .default_toolkit import DEFAULT_TOOLKIT
45

5-
__all__ = ["Tool", "Toolkit"]
6+
__all__ = ["Tool", "Toolkit", "DEFAULT_TOOLKIT"]
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
from .models import Tool, Toolkit
2+
from jupyter_ai_tools.toolkits.code_execution import bash
3+
4+
import pathlib
5+
6+
7+
def read(file_path: str, offset: int, limit: int) -> str:
8+
"""
9+
Read a subset of lines from a text file.
10+
11+
Parameters
12+
----------
13+
file_path : str
14+
Absolute path to the file that should be read.
15+
offset : int
16+
The line number at which to start reading (1-based indexing).
17+
limit : int
18+
Number of lines to read starting from *offset*.
19+
If *offset + limit* exceeds the number of lines in the file,
20+
all available lines after *offset* are returned.
21+
22+
Returns
23+
-------
24+
List[str]
25+
List of lines (including line-ending characters) that were read.
26+
27+
Examples
28+
--------
29+
>>> # Suppose ``/tmp/example.txt`` contains 10 lines
30+
>>> read('/tmp/example.txt', offset=3, limit=4)
31+
['third line\n', 'fourth line\n', 'fifth line\n', 'sixth line\n']
32+
"""
33+
path = pathlib.Path(file_path)
34+
if not path.is_file():
35+
raise FileNotFoundError(f"File not found: {file_path}")
36+
37+
# Normalize arguments
38+
offset = max(1, int(offset))
39+
limit = max(0, int(limit))
40+
lines: list[str] = []
41+
42+
with path.open(encoding='utf-8', errors='replace') as f:
43+
# Skip to offset
44+
line_no = 0
45+
# Loop invariant: line_no := last read line
46+
# After the loop exits, line_no == offset - 1, meaning the
47+
# next line starts at `offset`
48+
while line_no < offset - 1:
49+
line = f.readline()
50+
# Return early if offset exceeds number of lines in file
51+
if line == "":
52+
return ""
53+
line_no += 1
54+
55+
# Append lines until limit is reached
56+
while len(lines) < limit:
57+
line = f.readline()
58+
if line == "":
59+
break
60+
lines.append(line)
61+
62+
return "".join(lines)
63+
64+
65+
def edit(
66+
file_path: str,
67+
old_string: str,
68+
new_string: str,
69+
replace_all: bool = False,
70+
) -> None:
71+
"""
72+
Replace occurrences of a substring in a file.
73+
74+
Parameters
75+
----------
76+
file_path : str
77+
Absolute path to the file that should be edited.
78+
old_string : str
79+
Text that should be replaced.
80+
new_string : str
81+
Text that will replace *old_string*.
82+
replace_all : bool, optional
83+
If ``True`` all occurrences of *old_string* are replaced.
84+
If ``False`` (default), only the first occurrence in the file is replaced.
85+
86+
Returns
87+
-------
88+
None
89+
90+
Raises
91+
------
92+
FileNotFoundError
93+
If *file_path* does not exist.
94+
ValueError
95+
If *old_string* is empty (replacing an empty string is ambiguous).
96+
97+
Notes
98+
-----
99+
The file is overwritten atomically: it is first read into memory,
100+
the substitution is performed, and the file is written back.
101+
This keeps the operation safe for short to medium-sized files.
102+
103+
Examples
104+
--------
105+
>>> # Replace only the first occurrence
106+
>>> edit('/tmp/test.txt', 'foo', 'bar', replace_all=False)
107+
>>> # Replace all occurrences
108+
>>> edit('/tmp/test.txt', 'foo', 'bar', replace_all=True)
109+
"""
110+
path = pathlib.Path(file_path)
111+
if not path.is_file():
112+
raise FileNotFoundError(f"File not found: {file_path}")
113+
114+
if old_string == "":
115+
raise ValueError("old_string must not be empty")
116+
117+
# Read the entire file
118+
content = path.read_text(encoding="utf-8", errors="replace")
119+
120+
# Perform replacement
121+
if replace_all:
122+
new_content = content.replace(old_string, new_string)
123+
else:
124+
new_content = content.replace(old_string, new_string, 1)
125+
126+
# Write back
127+
path.write_text(new_content, encoding="utf-8")
128+
129+
130+
def write(file_path: str, content: str) -> None:
131+
"""
132+
Write content to a file, creating it if it doesn't exist.
133+
134+
Parameters
135+
----------
136+
file_path : str
137+
Absolute path to the file that should be written.
138+
content : str
139+
Content to write to the file.
140+
141+
Returns
142+
-------
143+
None
144+
145+
Raises
146+
------
147+
OSError
148+
If the file cannot be written (e.g., permission denied, invalid path).
149+
150+
Notes
151+
-----
152+
This function will overwrite the file if it already exists.
153+
The parent directory must exist; this function does not create directories.
154+
155+
Examples
156+
--------
157+
>>> write('/tmp/example.txt', 'Hello, world!')
158+
>>> write('/tmp/data.json', '{"key": "value"}')
159+
"""
160+
path = pathlib.Path(file_path)
161+
162+
# Write the content to the file
163+
path.write_text(content, encoding="utf-8")
164+
165+
166+
async def search_grep(pattern: str, include: str = "*") -> str:
167+
"""
168+
Search for text patterns in files using ripgrep.
169+
170+
This function uses ripgrep (rg) to perform fast regex-based text searching
171+
across files, with optional file filtering based on glob patterns.
172+
173+
Parameters
174+
----------
175+
pattern : str
176+
A regular expression pattern to search for. Ripgrep uses Rust regex
177+
syntax which supports:
178+
- Basic regex features: ., *, +, ?, ^, $, [], (), |
179+
- Character classes: \w, \d, \s, \W, \D, \S
180+
- Unicode categories: \p{L}, \p{N}, \p{P}, etc.
181+
- Word boundaries: \b, \B
182+
- Anchors: ^, $, \A, \z
183+
- Quantifiers: {n}, {n,}, {n,m}
184+
- Groups: (pattern), (?:pattern), (?P<name>pattern)
185+
- Lookahead/lookbehind: (?=pattern), (?!pattern), (?<=pattern), (?<!pattern)
186+
- Flags: (?i), (?m), (?s), (?x), (?U)
187+
188+
Note: Ripgrep uses Rust's regex engine, which does NOT support:
189+
- Backreferences (use --pcre2 flag for this)
190+
- Some advanced PCRE features
191+
include : str, optional
192+
A glob pattern to filter which files to search. Defaults to "*" (all files).
193+
Glob patterns follow gitignore syntax:
194+
- * matches any sequence of characters except /
195+
- ? matches any single character except /
196+
- ** matches any sequence of characters including /
197+
- [abc] matches any character in the set
198+
- {a,b} matches either "a" or "b"
199+
- ! at start negates the pattern
200+
Examples: "*.py", "**/*.js", "src/**/*.{ts,tsx}", "!*.test.*"
201+
202+
Returns
203+
-------
204+
str
205+
The raw output from ripgrep, including file paths, line numbers,
206+
and matching lines. Empty string if no matches found.
207+
208+
Raises
209+
------
210+
RuntimeError
211+
If ripgrep command fails or encounters an error (non-zero exit code).
212+
This includes cases where:
213+
- Pattern syntax is invalid
214+
- Include glob pattern is malformed
215+
- Ripgrep binary is not available
216+
- File system errors occur
217+
218+
Examples
219+
--------
220+
>>> search_grep(r"def\s+\w+", "*.py")
221+
'file.py:10:def my_function():'
222+
223+
>>> search_grep(r"TODO|FIXME", "**/*.{py,js}")
224+
'app.py:25:# TODO: implement this
225+
script.js:15:// FIXME: handle edge case'
226+
227+
>>> search_grep(r"class\s+(\w+)", "src/**/*.py")
228+
'src/models.py:1:class User:'
229+
"""
230+
# Use bash tool to execute ripgrep
231+
cmd_parts = ["rg", "--color=never", "--line-number", "--with-filename"]
232+
233+
# Add glob pattern if specified
234+
if include != "*":
235+
cmd_parts.extend(["-g", include])
236+
237+
# Add the pattern (always quote it to handle special characters)
238+
cmd_parts.append(pattern)
239+
240+
# Join command with proper shell escaping
241+
command = " ".join(f'"{part}"' if " " in part or any(c in part for c in "!*?[]{}()") else part for part in cmd_parts)
242+
243+
try:
244+
result = await bash(command)
245+
return result
246+
except Exception as e:
247+
raise RuntimeError(f"Ripgrep search failed: {str(e)}") from e
248+
249+
250+
DEFAULT_TOOLKIT = Toolkit(name="jupyter-ai-default-toolkit")
251+
DEFAULT_TOOLKIT.add_tool(Tool(callable=bash))
252+
DEFAULT_TOOLKIT.add_tool(Tool(callable=read))
253+
DEFAULT_TOOLKIT.add_tool(Tool(callable=edit))
254+
DEFAULT_TOOLKIT.add_tool(Tool(callable=write))
255+
DEFAULT_TOOLKIT.add_tool(Tool(callable=search_grep))

0 commit comments

Comments
 (0)