anycode-py 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.
anycode/tasks/task.py ADDED
@@ -0,0 +1,104 @@
1
+ """Task utilities — creation, readiness checks, topological sort, and cycle detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from uuid import uuid4
7
+
8
+ from anycode.types import Task
9
+
10
+
11
+ def create_task(
12
+ *, title: str, description: str, assignee: str | None = None, depends_on: list[str] | None = None
13
+ ) -> Task:
14
+ now = datetime.now(UTC)
15
+ return Task(
16
+ id=str(uuid4()),
17
+ title=title,
18
+ description=description,
19
+ status="pending",
20
+ assignee=assignee,
21
+ depends_on=list(depends_on) if depends_on else None,
22
+ created_at=now,
23
+ updated_at=now,
24
+ )
25
+
26
+
27
+ def is_task_ready(task: Task, all_tasks: list[Task], task_by_id: dict[str, Task] | None = None) -> bool:
28
+ """True when task is pending and every dependency has completed."""
29
+ if task.status != "pending":
30
+ return False
31
+ if not task.depends_on:
32
+ return True
33
+ lookup = task_by_id or {t.id: t for t in all_tasks}
34
+ stub = Task(id="", title="", description="", created_at=datetime.min, updated_at=datetime.min)
35
+ return all(lookup.get(dep_id, stub).status == "completed" for dep_id in task.depends_on)
36
+
37
+
38
+ def get_task_dependency_order(tasks: list[Task]) -> list[Task]:
39
+ """Topological ordering via Kahn's algorithm."""
40
+ if not tasks:
41
+ return []
42
+
43
+ task_by_id = {t.id: t for t in tasks}
44
+ in_degree: dict[str, int] = {t.id: 0 for t in tasks}
45
+ successors: dict[str, list[str]] = {t.id: [] for t in tasks}
46
+
47
+ for task in tasks:
48
+ for dep_id in task.depends_on or []:
49
+ if dep_id in task_by_id:
50
+ in_degree[task.id] = in_degree.get(task.id, 0) + 1
51
+ successors.setdefault(dep_id, []).append(task.id)
52
+
53
+ frontier = [tid for tid, deg in in_degree.items() if deg == 0]
54
+ ordered: list[Task] = []
55
+
56
+ while frontier:
57
+ tid = frontier.pop(0)
58
+ task = task_by_id.get(tid)
59
+ if task:
60
+ ordered.append(task)
61
+ for succ in successors.get(tid, []):
62
+ in_degree[succ] -= 1
63
+ if in_degree[succ] == 0:
64
+ frontier.append(succ)
65
+
66
+ return ordered
67
+
68
+
69
+ def validate_task_dependencies(tasks: list[Task]) -> tuple[bool, list[str]]:
70
+ """Check dependency graph for missing refs, self-references, and cycles."""
71
+ errors: list[str] = []
72
+ task_by_id = {t.id: t for t in tasks}
73
+
74
+ for task in tasks:
75
+ for dep_id in task.depends_on or []:
76
+ if dep_id == task.id:
77
+ errors.append(f'Task "{task.title}" ({task.id}) has a self-dependency.')
78
+ continue
79
+ if dep_id not in task_by_id:
80
+ errors.append(f'Task "{task.title}" ({task.id}) references missing dependency "{dep_id}".')
81
+
82
+ # Cycle detection via DFS colouring
83
+ color: dict[str, int] = {t.id: 0 for t in tasks} # 0=white, 1=gray, 2=black
84
+
85
+ def visit(tid: str, path: list[str]) -> None:
86
+ if color.get(tid) == 2:
87
+ return
88
+ if color.get(tid) == 1:
89
+ cycle_start = path.index(tid)
90
+ cycle = path[cycle_start:] + [tid]
91
+ errors.append(f"Dependency cycle found: {' -> '.join(cycle)}")
92
+ return
93
+ color[tid] = 1
94
+ task = task_by_id.get(tid)
95
+ for dep_id in (task.depends_on or []) if task else []:
96
+ if dep_id in task_by_id:
97
+ visit(dep_id, [*path, tid])
98
+ color[tid] = 2
99
+
100
+ for task in tasks:
101
+ if color.get(task.id) == 0:
102
+ visit(task.id, [])
103
+
104
+ return (len(errors) == 0, errors)
@@ -0,0 +1,21 @@
1
+ from anycode.tools.bash import bash_tool
2
+ from anycode.tools.built_in import BUILT_IN_TOOLS, register_built_in_tools
3
+ from anycode.tools.executor import ToolExecutor
4
+ from anycode.tools.file_edit import file_edit_tool
5
+ from anycode.tools.file_read import file_read_tool
6
+ from anycode.tools.file_write import file_write_tool
7
+ from anycode.tools.grep import grep_tool
8
+ from anycode.tools.registry import ToolRegistry, define_tool
9
+
10
+ __all__ = [
11
+ "ToolRegistry",
12
+ "ToolExecutor",
13
+ "define_tool",
14
+ "register_built_in_tools",
15
+ "BUILT_IN_TOOLS",
16
+ "bash_tool",
17
+ "file_read_tool",
18
+ "file_write_tool",
19
+ "file_edit_tool",
20
+ "grep_tool",
21
+ ]
anycode/tools/bash.py ADDED
@@ -0,0 +1,67 @@
1
+ """Shell execution tool — runs commands with configurable timeout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from anycode.tools.registry import define_tool
10
+ from anycode.types import ToolResult, ToolUseContext
11
+
12
+ TIMEOUT_LIMIT_S = 30
13
+
14
+
15
+ class BashInput(BaseModel):
16
+ command: str = Field(description="The shell command to run.")
17
+ timeout: float | None = Field(default=None, description=f"Time limit in seconds. Defaults to {TIMEOUT_LIMIT_S}s.")
18
+ cwd: str | None = Field(default=None, description="Directory to execute the command in.")
19
+
20
+
21
+ async def _execute(input: BashInput, context: ToolUseContext) -> ToolResult:
22
+ limit = input.timeout or TIMEOUT_LIMIT_S
23
+ stdout, stderr, exit_code = await _exec_command(input.command, cwd=input.cwd, timeout=limit)
24
+ return ToolResult(data=_compose_result(stdout, stderr, exit_code), is_error=exit_code != 0)
25
+
26
+
27
+ async def _exec_command(command: str, cwd: str | None, timeout: float) -> tuple[str, str, int]:
28
+ try:
29
+ proc = await asyncio.create_subprocess_shell(
30
+ command,
31
+ stdout=asyncio.subprocess.PIPE,
32
+ stderr=asyncio.subprocess.PIPE,
33
+ cwd=cwd,
34
+ )
35
+ try:
36
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
37
+ except TimeoutError:
38
+ proc.kill()
39
+ await proc.wait()
40
+ return "", "Process timed out", 124
41
+ return (
42
+ stdout_bytes.decode("utf-8", errors="replace"),
43
+ stderr_bytes.decode("utf-8", errors="replace"),
44
+ proc.returncode or 0,
45
+ )
46
+ except Exception as e:
47
+ return "", str(e), 127
48
+
49
+
50
+ def _compose_result(stdout: str, stderr: str, exit_code: int) -> str:
51
+ parts: list[str] = []
52
+ if stdout:
53
+ parts.append(stdout)
54
+ if stderr:
55
+ parts.append(f"-- stderr --\n{stderr}" if stdout else stderr)
56
+ if not parts:
57
+ return "(completed silently — no output)" if exit_code == 0 else f"(exited with code {exit_code} — no output produced)"
58
+ if exit_code != 0:
59
+ parts.append(f"\n(exit code {exit_code})")
60
+ return "\n".join(parts)
61
+
62
+
63
+ bash_tool = define_tool(name="bash", description=(
64
+ "Run a shell command and capture its stdout and stderr. "
65
+ "Useful for file-system tasks, script execution, package management, "
66
+ "or anything requiring a shell session."
67
+ ), input_model=BashInput, execute=_execute)
@@ -0,0 +1,18 @@
1
+ """Default tool set — bulk-register every bundled tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from anycode.tools.bash import bash_tool
6
+ from anycode.tools.file_edit import file_edit_tool
7
+ from anycode.tools.file_read import file_read_tool
8
+ from anycode.tools.file_write import file_write_tool
9
+ from anycode.tools.grep import grep_tool
10
+ from anycode.tools.registry import ToolRegistry
11
+ from anycode.types import ToolDefinition
12
+
13
+ BUILT_IN_TOOLS: list[ToolDefinition] = [bash_tool, file_read_tool, file_write_tool, file_edit_tool, grep_tool]
14
+
15
+
16
+ def register_built_in_tools(registry: ToolRegistry) -> None:
17
+ for tool in BUILT_IN_TOOLS:
18
+ registry.register(tool)
@@ -0,0 +1,51 @@
1
+ """Concurrent tool dispatcher with Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from anycode.helpers.concurrency_gate import Semaphore
9
+ from anycode.tools.registry import ToolRegistry
10
+ from anycode.types import BatchToolCall, ToolDefinition, ToolResult, ToolUseContext
11
+
12
+
13
+ class ToolExecutor:
14
+ """Validates inputs via Pydantic then invokes tools, with semaphore-bounded batch execution."""
15
+
16
+ def __init__(self, registry: ToolRegistry, max_concurrency: int = 4) -> None:
17
+ self._registry = registry
18
+ self._semaphore = Semaphore(max_concurrency)
19
+
20
+ async def execute(self, tool_name: str, raw_input: dict[str, Any], context: ToolUseContext) -> ToolResult:
21
+ tool = self._registry.get(tool_name)
22
+ if tool is None:
23
+ return _failure(f'Tool "{tool_name}" is not registered in the current registry.')
24
+ return await self._invoke(tool, raw_input, context)
25
+
26
+ async def execute_batch(
27
+ self, calls: list[BatchToolCall], context: ToolUseContext
28
+ ) -> dict[str, ToolResult]:
29
+ results: dict[str, ToolResult] = {}
30
+
31
+ async def _run(call: BatchToolCall) -> None:
32
+ result = await self._semaphore.run(lambda: self.execute(call.name, call.input, context))
33
+ results[call.id] = result
34
+
35
+ await asyncio.gather(*[_run(c) for c in calls])
36
+ return results
37
+
38
+ async def _invoke(self, tool: ToolDefinition, raw_input: dict[str, Any], context: ToolUseContext) -> ToolResult:
39
+ try:
40
+ validated = tool.input_model.model_validate(raw_input)
41
+ except Exception as e:
42
+ return _failure(f'Invalid input for tool "{tool.name}": {e}')
43
+
44
+ try:
45
+ return await tool.execute(validated, context)
46
+ except Exception as e:
47
+ return _failure(f'Tool "{tool.name}" raised an error: {e}')
48
+
49
+
50
+ def _failure(message: str) -> ToolResult:
51
+ return ToolResult(data=message, is_error=True)
@@ -0,0 +1,70 @@
1
+ """Targeted string replacement tool — edits existing files in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from anycode.tools.registry import define_tool
10
+ from anycode.types import ToolResult, ToolUseContext
11
+
12
+
13
+ class FileEditInput(BaseModel):
14
+ path: str = Field(description="Absolute path of the target file.")
15
+ old_string: str = Field(description="The exact text to locate and replace.")
16
+ new_string: str = Field(description="The substitution text.")
17
+ replace_all: bool = Field(default=False, description="When true, replace every match.")
18
+
19
+
20
+ async def _execute(input: FileEditInput, context: ToolUseContext) -> ToolResult:
21
+ try:
22
+ original = Path(input.path).read_text(encoding="utf-8")
23
+ except Exception as e:
24
+ return ToolResult(data=f'Unable to read "{input.path}": {e}', is_error=True)
25
+
26
+ hits = _tally(original, input.old_string)
27
+
28
+ if hits == 0:
29
+ return ToolResult(
30
+ data=f'The target string was not found in "{input.path}".\nVerify that old_string matches the file content exactly.',
31
+ is_error=True,
32
+ )
33
+
34
+ if hits > 1 and not input.replace_all:
35
+ return ToolResult(
36
+ data=f'old_string appears {hits} times in "{input.path}". Use a more specific string or set replace_all=True.',
37
+ is_error=True,
38
+ )
39
+
40
+ updated = original.replace(input.old_string, input.new_string) if input.replace_all else original.replace(input.old_string, input.new_string, 1)
41
+
42
+ try:
43
+ Path(input.path).write_text(updated, encoding="utf-8")
44
+ except Exception as e:
45
+ return ToolResult(data=f'Unable to write "{input.path}": {e}', is_error=True)
46
+
47
+ replaced = hits if input.replace_all else 1
48
+ return ToolResult(data=f'Replaced {replaced} occurrence{"s" if replaced != 1 else ""} in "{input.path}".', is_error=False)
49
+
50
+
51
+ def _tally(source: str, search: str) -> int:
52
+ if not search:
53
+ return 0
54
+ count = 0
55
+ pos = 0
56
+ while True:
57
+ pos = source.find(search, pos)
58
+ if pos == -1:
59
+ break
60
+ count += 1
61
+ pos += len(search)
62
+ return count
63
+
64
+
65
+ file_edit_tool = define_tool(
66
+ name="file_edit",
67
+ description="Modify a file by swapping a specific string with new content. The old_string must match verbatim within the file.",
68
+ input_model=FileEditInput,
69
+ execute=_execute,
70
+ )
@@ -0,0 +1,52 @@
1
+ """File reader tool — retrieves file contents with optional range windowing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from anycode.tools.registry import define_tool
10
+ from anycode.types import ToolResult, ToolUseContext
11
+
12
+
13
+ class FileReadInput(BaseModel):
14
+ path: str = Field(description="Absolute path of the file to read.")
15
+ offset: int | None = Field(default=None, description="1-based starting line number.")
16
+ limit: int | None = Field(default=None, description="Maximum lines to return.")
17
+
18
+
19
+ async def _execute(input: FileReadInput, context: ToolUseContext) -> ToolResult:
20
+ try:
21
+ raw = Path(input.path).read_text(encoding="utf-8")
22
+ except Exception as e:
23
+ return ToolResult(data=f'Unable to read "{input.path}": {e}', is_error=True)
24
+
25
+ lines = raw.split("\n")
26
+ if lines and lines[-1] == "":
27
+ lines.pop()
28
+
29
+ total = len(lines)
30
+ start = max(0, (input.offset or 1) - 1)
31
+
32
+ if start >= total > 0:
33
+ return ToolResult(
34
+ data=f'File "{input.path}" contains {total} line{"s" if total != 1 else ""} but offset {input.offset} exceeds the range.',
35
+ is_error=True,
36
+ )
37
+
38
+ end = min(start + input.limit, total) if input.limit else total
39
+ numbered = "\n".join(f"{start + i + 1}\t{line}" for i, line in enumerate(lines[start:end]))
40
+ meta = f"\n\n(showing lines {start + 1}\u2013{end} of {total})" if end < total else ""
41
+ return ToolResult(data=numbered + meta, is_error=False)
42
+
43
+
44
+ file_read_tool = define_tool(
45
+ name="file_read",
46
+ description=(
47
+ "Retrieve the contents of a file from disk. Output includes line numbers in the format 'N\\t<line>'."
48
+ " Specify offset and limit to page through large files."
49
+ ),
50
+ input_model=FileReadInput,
51
+ execute=_execute,
52
+ )
@@ -0,0 +1,49 @@
1
+ """File writer tool — creates or replaces files, auto-creating parent directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from anycode.tools.registry import define_tool
10
+ from anycode.types import ToolResult, ToolUseContext
11
+
12
+
13
+ class FileWriteInput(BaseModel):
14
+ path: str = Field(description="Absolute path of the file to write.")
15
+ content: str = Field(description="Complete content to place in the file.")
16
+
17
+
18
+ async def _execute(input: FileWriteInput, context: ToolUseContext) -> ToolResult:
19
+ target = Path(input.path)
20
+ existed = target.exists()
21
+
22
+ try:
23
+ target.parent.mkdir(parents=True, exist_ok=True)
24
+ except Exception as e:
25
+ return ToolResult(data=f'Could not create parent directory "{target.parent}": {e}', is_error=True)
26
+
27
+ try:
28
+ target.write_text(input.content, encoding="utf-8")
29
+ except Exception as e:
30
+ return ToolResult(data=f'Could not write file "{input.path}": {e}', is_error=True)
31
+
32
+ line_count = input.content.count("\n") + (1 if input.content and not input.content.endswith("\n") else 0)
33
+ byte_count = len(input.content.encode("utf-8"))
34
+ action = "Overwrote" if existed else "Created"
35
+ return ToolResult(
36
+ data=f'{action} "{input.path}" ({line_count} line{"s" if line_count != 1 else ""}, {byte_count} bytes).',
37
+ is_error=False,
38
+ )
39
+
40
+
41
+ file_write_tool = define_tool(
42
+ name="file_write",
43
+ description=(
44
+ "Write content to a file. Creates the file and any missing parent directories if it does not exist,"
45
+ " or overwrites the file if it does."
46
+ ),
47
+ input_model=FileWriteInput,
48
+ execute=_execute,
49
+ )
anycode/tools/grep.py ADDED
@@ -0,0 +1,127 @@
1
+ """Regex search tool — ripgrep fast-path with Python fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from anycode.tools.registry import define_tool
14
+ from anycode.types import ToolResult, ToolUseContext
15
+
16
+ MATCH_CEILING = 100
17
+ IGNORED_DIRS = {".git", ".svn", ".hg", "node_modules", ".next", "dist", "build", "__pycache__", ".venv"}
18
+
19
+
20
+ class GrepInput(BaseModel):
21
+ pattern: str = Field(description="Regex pattern to search for.")
22
+ path: str | None = Field(default=None, description="Directory or file to search. Defaults to cwd.")
23
+ glob: str | None = Field(default=None, description='Glob filter for filenames (e.g. "*.py").')
24
+ max_results: int = Field(default=MATCH_CEILING, description="Upper bound on matching lines.")
25
+
26
+
27
+ async def _execute(input: GrepInput, context: ToolUseContext) -> ToolResult:
28
+ search_path = input.path or os.getcwd()
29
+ cap = input.max_results
30
+
31
+ try:
32
+ regex = re.compile(input.pattern)
33
+ except re.error:
34
+ return ToolResult(data=f'Invalid regex pattern: "{input.pattern}"', is_error=True)
35
+
36
+ if _has_ripgrep():
37
+ return await _ripgrep_search(input.pattern, search_path, glob=input.glob, max_results=cap)
38
+ return await _python_search(regex, search_path, glob=input.glob, max_results=cap)
39
+
40
+
41
+ async def _ripgrep_search(
42
+ pattern: str, search_path: str, *, glob: str | None, max_results: int
43
+ ) -> ToolResult:
44
+ args = ["rg", "--line-number", "--no-heading", "--color=never", f"--max-count={max_results}"]
45
+ if glob:
46
+ args.extend(["--glob", glob])
47
+ args.extend(["--", pattern, search_path])
48
+
49
+ try:
50
+ proc = await asyncio.create_subprocess_exec(
51
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
52
+ )
53
+ stdout, stderr = await proc.communicate()
54
+ output = stdout.decode("utf-8", errors="replace").strip()
55
+ if proc.returncode not in (0, 1):
56
+ return ToolResult(data=f"ripgrep exited with code {proc.returncode}: {stderr.decode().strip()}", is_error=True)
57
+ return ToolResult(data=output or "No matches.", is_error=False)
58
+ except Exception as e:
59
+ return ToolResult(data=f"ripgrep error: {e}", is_error=True)
60
+
61
+
62
+ async def _python_search(
63
+ regex: re.Pattern[str], search_path: str, *, glob: str | None, max_results: int
64
+ ) -> ToolResult:
65
+ target = Path(search_path)
66
+ try:
67
+ files = [target] if target.is_file() else list(_gather_files(target, glob))
68
+ except Exception as e:
69
+ return ToolResult(data=f'Cannot access "{search_path}": {e}', is_error=True)
70
+
71
+ hits: list[str] = []
72
+ for file in files:
73
+ if len(hits) >= max_results:
74
+ break
75
+ try:
76
+ content = file.read_text(encoding="utf-8", errors="replace")
77
+ except Exception:
78
+ continue
79
+ for i, line in enumerate(content.split("\n")):
80
+ if len(hits) >= max_results:
81
+ break
82
+ if regex.search(line):
83
+ rel = os.path.relpath(file) if file.is_absolute() else str(file)
84
+ hits.append(f"{rel}:{i + 1}:{line}")
85
+
86
+ if not hits:
87
+ return ToolResult(data="No matches.", is_error=False)
88
+ note = f"\n\n(results capped at {max_results} — increase max_results for more)" if len(hits) >= max_results else ""
89
+ return ToolResult(data="\n".join(hits) + note, is_error=False)
90
+
91
+
92
+ def _gather_files(directory: Path, glob_pattern: str | None) -> list[Path]:
93
+ results: list[Path] = []
94
+ _traverse(directory, glob_pattern, results)
95
+ return results
96
+
97
+
98
+ def _traverse(directory: Path, glob_pattern: str | None, results: list[Path]) -> None:
99
+ try:
100
+ entries = list(directory.iterdir())
101
+ except PermissionError:
102
+ return
103
+ for entry in entries:
104
+ if entry.is_dir():
105
+ if entry.name not in IGNORED_DIRS:
106
+ _traverse(entry, glob_pattern, results)
107
+ elif entry.is_file():
108
+ if glob_pattern is None or _glob_match(entry.name, glob_pattern):
109
+ results.append(entry)
110
+
111
+
112
+ def _glob_match(filename: str, glob_pattern: str) -> bool:
113
+ pattern = glob_pattern.removeprefix("**/")
114
+ regex_src = re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".")
115
+ return bool(re.match(f"^{regex_src}$", filename, re.IGNORECASE))
116
+
117
+
118
+ def _has_ripgrep() -> bool:
119
+ return shutil.which("rg") is not None
120
+
121
+
122
+ grep_tool = define_tool(
123
+ name="grep",
124
+ description="Search files for a regular-expression pattern. Returns matching lines with file paths and line numbers.",
125
+ input_model=GrepInput,
126
+ execute=_execute,
127
+ )
@@ -0,0 +1,84 @@
1
+ """Tool declaration, registration, and schema conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from anycode.types import LLMToolDef, ToolDefinition, ToolResult
11
+
12
+
13
+ def define_tool(
14
+ *,
15
+ name: str,
16
+ description: str,
17
+ input_model: type[BaseModel],
18
+ execute: Callable[..., Awaitable[ToolResult]],
19
+ ) -> ToolDefinition:
20
+ """Create a typed tool definition."""
21
+ return ToolDefinition(name=name, description=description, input_model=input_model, execute=execute)
22
+
23
+
24
+ class ToolRegistry:
25
+ """Named store that converts entries to JSON Schema for LLM providers."""
26
+
27
+ def __init__(self) -> None:
28
+ self._tools: dict[str, ToolDefinition] = {}
29
+
30
+ def register(self, tool: ToolDefinition) -> None:
31
+ if tool.name in self._tools:
32
+ raise ValueError(
33
+ f'ToolRegistry: "{tool.name}" is already registered. '
34
+ "Choose a unique name or remove the existing entry first."
35
+ )
36
+ self._tools[tool.name] = tool
37
+
38
+ def get(self, name: str) -> ToolDefinition | None:
39
+ return self._tools.get(name)
40
+
41
+ def list(self) -> list[ToolDefinition]:
42
+ return list(self._tools.values())
43
+
44
+ def has(self, name: str) -> bool:
45
+ return name in self._tools
46
+
47
+ def deregister(self, name: str) -> None:
48
+ self._tools.pop(name, None)
49
+
50
+ def to_tool_defs(self) -> list[LLMToolDef]:
51
+ return [
52
+ LLMToolDef(
53
+ name=tool.name,
54
+ description=tool.description,
55
+ input_schema=_model_to_json_schema(tool.input_model),
56
+ )
57
+ for tool in self._tools.values()
58
+ ]
59
+
60
+ def to_llm_tools(self) -> list[dict[str, Any]]:
61
+ """Anthropic-specific format with input_schema."""
62
+ result = []
63
+ for tool in self._tools.values():
64
+ schema = _model_to_json_schema(tool.input_model)
65
+ result.append({
66
+ "name": tool.name,
67
+ "description": tool.description,
68
+ "input_schema": {
69
+ "type": "object",
70
+ "properties": schema.get("properties", {}),
71
+ **({"required": schema["required"]} if "required" in schema else {}),
72
+ },
73
+ })
74
+ return result
75
+
76
+
77
+ def _model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
78
+ """Convert a Pydantic model to JSON Schema suitable for LLM tool definitions."""
79
+ schema = model.model_json_schema()
80
+ # Remove pydantic metadata fields not needed by LLMs
81
+ schema.pop("title", None)
82
+ for prop in schema.get("properties", {}).values():
83
+ prop.pop("title", None)
84
+ return schema