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/__init__.py +121 -0
- anycode/collaboration/__init__.py +6 -0
- anycode/collaboration/kv_store.py +49 -0
- anycode/collaboration/message_bus.py +70 -0
- anycode/collaboration/shared_mem.py +55 -0
- anycode/collaboration/team.py +96 -0
- anycode/core/__init__.py +7 -0
- anycode/core/agent.py +141 -0
- anycode/core/orchestrator.py +267 -0
- anycode/core/pool.py +99 -0
- anycode/core/runner.py +176 -0
- anycode/core/scheduler.py +126 -0
- anycode/helpers/__init__.py +4 -0
- anycode/helpers/concurrency_gate.py +45 -0
- anycode/helpers/usage_tracker.py +14 -0
- anycode/providers/__init__.py +3 -0
- anycode/providers/adapter.py +22 -0
- anycode/providers/anthropic.py +149 -0
- anycode/providers/openai.py +208 -0
- anycode/tasks/__init__.py +4 -0
- anycode/tasks/queue.py +139 -0
- anycode/tasks/task.py +104 -0
- anycode/tools/__init__.py +21 -0
- anycode/tools/bash.py +67 -0
- anycode/tools/built_in.py +18 -0
- anycode/tools/executor.py +51 -0
- anycode/tools/file_edit.py +70 -0
- anycode/tools/file_read.py +52 -0
- anycode/tools/file_write.py +49 -0
- anycode/tools/grep.py +127 -0
- anycode/tools/registry.py +84 -0
- anycode/types.py +313 -0
- anycode_py-0.1.0.dist-info/METADATA +399 -0
- anycode_py-0.1.0.dist-info/RECORD +36 -0
- anycode_py-0.1.0.dist-info/WHEEL +4 -0
- anycode_py-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|