citrus-code 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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import unicodedata
|
|
2
|
+
from bisect import bisect_right
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TextEdit(BaseModel):
|
|
9
|
+
model_config = ConfigDict(
|
|
10
|
+
extra="forbid",
|
|
11
|
+
strict=True,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
old_text: str = Field(
|
|
15
|
+
alias="oldText",
|
|
16
|
+
min_length=1,
|
|
17
|
+
description=(
|
|
18
|
+
"Exact text to replace. It must match "
|
|
19
|
+
"a unique region of the original file."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
new_text: str = Field(
|
|
24
|
+
alias="newText",
|
|
25
|
+
description="The replacement text.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MatchedEdit:
|
|
31
|
+
edit_index: int
|
|
32
|
+
start: int
|
|
33
|
+
end: int
|
|
34
|
+
new_text: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_to_lf(text: str) -> str:
|
|
38
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_for_match(text: str) -> str:
|
|
42
|
+
"""Pi's fallback: normalize typography and trailing whitespace, not indentation."""
|
|
43
|
+
text = unicodedata.normalize("NFKC", text)
|
|
44
|
+
text = "\n".join(line.rstrip() for line in text.split("\n"))
|
|
45
|
+
replacements = {
|
|
46
|
+
**dict.fromkeys("\u2018\u2019\u201a\u201b", "'"),
|
|
47
|
+
**dict.fromkeys("\u201c\u201d\u201e\u201f", '"'),
|
|
48
|
+
**dict.fromkeys("\u2010\u2011\u2012\u2013\u2014\u2015\u2212", "-"),
|
|
49
|
+
**dict.fromkeys("\u00a0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000", " "),
|
|
50
|
+
}
|
|
51
|
+
return text.translate(str.maketrans(replacements))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply_text_edits(
|
|
55
|
+
content: str,
|
|
56
|
+
edits: list[TextEdit],
|
|
57
|
+
path: str,
|
|
58
|
+
) -> str:
|
|
59
|
+
if not edits:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"edits must contain at least one replacement"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
original = normalize_to_lf(content)
|
|
65
|
+
prepared = [
|
|
66
|
+
(normalize_to_lf(edit.old_text), normalize_to_lf(edit.new_text))
|
|
67
|
+
for edit in edits
|
|
68
|
+
]
|
|
69
|
+
use_fuzzy = any(old not in original for old, _ in prepared)
|
|
70
|
+
base = normalize_for_match(original) if use_fuzzy else original
|
|
71
|
+
|
|
72
|
+
matches: list[MatchedEdit] = []
|
|
73
|
+
|
|
74
|
+
# 所有匹配都针对同一份原始内容。
|
|
75
|
+
for edit_index, (old_text, new_text) in enumerate(prepared):
|
|
76
|
+
if use_fuzzy:
|
|
77
|
+
old_text = normalize_for_match(old_text)
|
|
78
|
+
if not old_text:
|
|
79
|
+
raise ValueError(f"edits[{edit_index}].oldText must not be empty in {path}")
|
|
80
|
+
|
|
81
|
+
start = base.find(old_text)
|
|
82
|
+
|
|
83
|
+
if start == -1:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Could not find edits[{edit_index}].oldText "
|
|
86
|
+
f"in {path}. The text must match exactly."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Typography-equivalent duplicates are ambiguous even with one exact hit.
|
|
90
|
+
uniqueness_text = normalize_for_match(base)
|
|
91
|
+
uniqueness_old = normalize_for_match(old_text)
|
|
92
|
+
if not uniqueness_old:
|
|
93
|
+
uniqueness_text, uniqueness_old = base, old_text
|
|
94
|
+
first_start = uniqueness_text.find(uniqueness_old)
|
|
95
|
+
second_start = uniqueness_text.find(uniqueness_old, first_start + 1)
|
|
96
|
+
|
|
97
|
+
if second_start != -1:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"edits[{edit_index}].oldText is not unique "
|
|
100
|
+
f"in {path}. Provide more surrounding text."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
matches.append(
|
|
104
|
+
MatchedEdit(
|
|
105
|
+
edit_index=edit_index,
|
|
106
|
+
start=start,
|
|
107
|
+
end=start + len(old_text),
|
|
108
|
+
new_text=new_text,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
matches.sort(key=lambda match: match.start)
|
|
113
|
+
|
|
114
|
+
# 排序之后,相邻区域就足以用于检测重叠。
|
|
115
|
+
for index in range(1, len(matches)):
|
|
116
|
+
previous = matches[index - 1]
|
|
117
|
+
current = matches[index]
|
|
118
|
+
|
|
119
|
+
if previous.end > current.start:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"edits[{previous.edit_index}] and "
|
|
122
|
+
f"edits[{current.edit_index}] overlap "
|
|
123
|
+
f"in {path}. Merge them into one edit."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not use_fuzzy:
|
|
127
|
+
result = original
|
|
128
|
+
# 从后往前替换,保持前面尚未处理的位置有效。
|
|
129
|
+
for match in reversed(matches):
|
|
130
|
+
result = result[:match.start] + match.new_text + result[match.end:]
|
|
131
|
+
else:
|
|
132
|
+
# Only rewrite lines touched by a normalized match. Untouched lines keep
|
|
133
|
+
# their original typography and trailing spaces.
|
|
134
|
+
original_lines = original.splitlines(keepends=True)
|
|
135
|
+
base_lines = base.splitlines(keepends=True)
|
|
136
|
+
if len(original_lines) != len(base_lines):
|
|
137
|
+
raise ValueError("Normalization changed the line structure")
|
|
138
|
+
offsets = [0]
|
|
139
|
+
for line in base_lines:
|
|
140
|
+
offsets.append(offsets[-1] + len(line))
|
|
141
|
+
groups: list[tuple[int, int, list[MatchedEdit]]] = []
|
|
142
|
+
for match in matches:
|
|
143
|
+
start_line = bisect_right(offsets, match.start) - 1
|
|
144
|
+
end_line = bisect_right(offsets, match.end - 1)
|
|
145
|
+
if groups and start_line < groups[-1][1]:
|
|
146
|
+
previous_start, previous_end, group_matches = groups[-1]
|
|
147
|
+
group_matches.append(match)
|
|
148
|
+
groups[-1] = (previous_start, max(previous_end, end_line), group_matches)
|
|
149
|
+
else:
|
|
150
|
+
groups.append((start_line, end_line, [match]))
|
|
151
|
+
|
|
152
|
+
pieces: list[str] = []
|
|
153
|
+
cursor = 0
|
|
154
|
+
for start_line, end_line, group_matches in groups:
|
|
155
|
+
pieces.extend(original_lines[cursor:start_line])
|
|
156
|
+
offset = offsets[start_line]
|
|
157
|
+
block = base[offset:offsets[end_line]]
|
|
158
|
+
for match in reversed(group_matches):
|
|
159
|
+
start, end = match.start - offset, match.end - offset
|
|
160
|
+
block = block[:start] + match.new_text + block[end:]
|
|
161
|
+
pieces.append(block)
|
|
162
|
+
cursor = end_line
|
|
163
|
+
pieces.extend(original_lines[cursor:])
|
|
164
|
+
result = "".join(pieces)
|
|
165
|
+
|
|
166
|
+
if result == original:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"No changes made to {path}. "
|
|
169
|
+
"The replacements produced identical content."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return result
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ...agent.types import AgentTool
|
|
5
|
+
from .bash import BashTool
|
|
6
|
+
from .edit import EditTool
|
|
7
|
+
from .find import FindTool
|
|
8
|
+
from .grep import GrepTool
|
|
9
|
+
from .ls import LsTool
|
|
10
|
+
from .powershell import PowerShellTool
|
|
11
|
+
from .read import ReadTool
|
|
12
|
+
from .write import WriteTool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_CODING_TOOLS = ("read", "bash", "edit", "write")
|
|
16
|
+
READ_ONLY_TOOLS = ("read", "grep", "find", "ls")
|
|
17
|
+
|
|
18
|
+
_FACTORIES: dict[str, Callable[[Path], AgentTool]] = {
|
|
19
|
+
"read": ReadTool,
|
|
20
|
+
"bash": BashTool,
|
|
21
|
+
"powershell": PowerShellTool,
|
|
22
|
+
"edit": EditTool,
|
|
23
|
+
"write": WriteTool,
|
|
24
|
+
"grep": GrepTool,
|
|
25
|
+
"find": FindTool,
|
|
26
|
+
"ls": LsTool,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_tool(name: str, cwd: Path) -> AgentTool:
|
|
31
|
+
factory = _FACTORIES.get(name)
|
|
32
|
+
if factory is None:
|
|
33
|
+
raise ValueError(f"Unknown built-in tool: {name}")
|
|
34
|
+
return factory(cwd)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_tools(cwd: Path, names: Iterable[str]) -> list[AgentTool]:
|
|
38
|
+
tools: list[AgentTool] = []
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
for name in names:
|
|
41
|
+
if name in seen:
|
|
42
|
+
raise ValueError(f"Duplicate built-in tool: {name}")
|
|
43
|
+
seen.add(name)
|
|
44
|
+
tools.append(create_tool(name, cwd))
|
|
45
|
+
return tools
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_coding_tools(cwd: Path) -> list[AgentTool]:
|
|
49
|
+
return create_tools(cwd, DEFAULT_CODING_TOOLS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_read_only_tools(cwd: Path) -> list[AgentTool]:
|
|
53
|
+
return create_tools(cwd, READ_ONLY_TOOLS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_all_tools(cwd: Path) -> dict[str, AgentTool]:
|
|
57
|
+
return {name: factory(cwd) for name, factory in _FACTORIES.items()}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from contextlib import aclosing
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
7
|
+
|
|
8
|
+
from ...agent.types import ToolExecutionResult
|
|
9
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
10
|
+
from .paths import resolve_to_cwd
|
|
11
|
+
from .processes import command_lines, find_executable
|
|
12
|
+
from .truncate import truncate_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FindArguments(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
17
|
+
|
|
18
|
+
pattern: str = Field(min_length=1, description="Glob pattern, e.g. '*.py' or 'src/**/*.py'.")
|
|
19
|
+
path: str = Field(default=".", description="Directory to search.")
|
|
20
|
+
limit: int = Field(default=1000, ge=1, description="Maximum results to return.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FindTool:
|
|
24
|
+
name: str = "find"
|
|
25
|
+
description: str = (
|
|
26
|
+
"Find paths by glob pattern, respecting .gitignore and including hidden "
|
|
27
|
+
"files. Results are relative to the search directory. Defaults to 1000 "
|
|
28
|
+
"results, with a 50KB output cap."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __init__(self, cwd: Path) -> None:
|
|
32
|
+
self._cwd = cwd.resolve()
|
|
33
|
+
|
|
34
|
+
def get_definition(self) -> ToolDefinition:
|
|
35
|
+
return ToolDefinition(
|
|
36
|
+
name=self.name,
|
|
37
|
+
description=self.description,
|
|
38
|
+
parameters=FindArguments.model_json_schema(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def execute(
|
|
42
|
+
self, tool_call_id: str, arguments: dict[str, JsonValue]
|
|
43
|
+
) -> ToolExecutionResult:
|
|
44
|
+
args = FindArguments.model_validate(arguments)
|
|
45
|
+
root = resolve_to_cwd(args.path, self._cwd)
|
|
46
|
+
if not root.is_dir():
|
|
47
|
+
raise NotADirectoryError(f"Not a directory: {root}")
|
|
48
|
+
command = [find_executable("fd"), "--glob", "--color=never", "--hidden", "--max-results", str(args.limit)]
|
|
49
|
+
if not any((parent / ".git").exists() for parent in (root, *root.parents)):
|
|
50
|
+
command.append("--no-require-git")
|
|
51
|
+
pattern = args.pattern
|
|
52
|
+
if "/" in pattern:
|
|
53
|
+
command.append("--full-path")
|
|
54
|
+
if not pattern.startswith(("/", "**/")) and pattern != "**":
|
|
55
|
+
pattern = "**/" + pattern
|
|
56
|
+
if os.name == "nt":
|
|
57
|
+
pattern = pattern.replace("/", r"[/\\]")
|
|
58
|
+
command.extend(["--", pattern, str(root)])
|
|
59
|
+
results: list[str] = []
|
|
60
|
+
async with aclosing(command_lines(command, self._cwd)) as stream:
|
|
61
|
+
async for line in stream:
|
|
62
|
+
trailing = line.endswith(("/", "\\"))
|
|
63
|
+
path = Path(line)
|
|
64
|
+
relative = path.relative_to(root) if path.is_absolute() else path
|
|
65
|
+
label = relative.as_posix()
|
|
66
|
+
if trailing and not label.endswith("/"):
|
|
67
|
+
label += "/"
|
|
68
|
+
results.append(label)
|
|
69
|
+
if len(results) >= args.limit:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if not results:
|
|
73
|
+
return ToolExecutionResult(content=[TextContent(text="No files found matching pattern")])
|
|
74
|
+
truncation = truncate_text("\n".join(results), max_lines=sys.maxsize)
|
|
75
|
+
text = truncation.content
|
|
76
|
+
details: dict[str, JsonValue] = {}
|
|
77
|
+
notices: list[str] = []
|
|
78
|
+
if len(results) >= args.limit:
|
|
79
|
+
details["resultLimitReached"] = args.limit
|
|
80
|
+
notices.append(f"{args.limit} results limit reached. Refine the pattern or increase limit")
|
|
81
|
+
if truncation.truncated:
|
|
82
|
+
details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
|
|
83
|
+
notices.append("50KB output limit reached")
|
|
84
|
+
if notices:
|
|
85
|
+
text += "\n\n[" + ". ".join(notices) + "]"
|
|
86
|
+
return ToolExecutionResult(content=[TextContent(text=text)], details=details)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import aclosing
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
9
|
+
|
|
10
|
+
from ...agent.types import ToolExecutionResult
|
|
11
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
12
|
+
from .paths import resolve_to_cwd
|
|
13
|
+
from .processes import command_lines, find_executable
|
|
14
|
+
from .truncate import DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, truncate_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GrepArguments(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
19
|
+
|
|
20
|
+
pattern: str = Field(description="Search pattern: regex, or a literal string when literal is true.")
|
|
21
|
+
path: str = Field(default=".", description="File or directory to search.")
|
|
22
|
+
glob: str | None = Field(default=None, description="Filter files, e.g. '*.py'.")
|
|
23
|
+
ignore_case: bool = Field(default=False, alias="ignoreCase")
|
|
24
|
+
literal: bool = False
|
|
25
|
+
context: int = Field(default=0, ge=0, description="Context lines before and after each match.")
|
|
26
|
+
limit: int = Field(default=100, ge=1, description="Maximum matching lines to return.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _RgText(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="ignore")
|
|
31
|
+
text: str | None = None
|
|
32
|
+
bytes: str | None = None
|
|
33
|
+
|
|
34
|
+
def value(self) -> str:
|
|
35
|
+
if self.text is not None:
|
|
36
|
+
return self.text
|
|
37
|
+
if self.bytes is not None:
|
|
38
|
+
return os.fsdecode(base64.b64decode(self.bytes))
|
|
39
|
+
raise ValueError("ripgrep returned an empty text field")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _RgData(BaseModel):
|
|
43
|
+
model_config = ConfigDict(extra="ignore")
|
|
44
|
+
path: _RgText | None = None
|
|
45
|
+
lines: _RgText | None = None
|
|
46
|
+
line_number: int | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _RgEvent(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="ignore")
|
|
51
|
+
type: str
|
|
52
|
+
data: _RgData | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GrepTool:
|
|
56
|
+
name: str = "grep"
|
|
57
|
+
description: str = (
|
|
58
|
+
"Search file contents using regex or literal text. Respects .gitignore, "
|
|
59
|
+
"includes hidden files, and returns paths with line numbers. Default limit "
|
|
60
|
+
"is 100 matching lines, with a 50KB output cap and 500 characters per line."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __init__(self, cwd: Path) -> None:
|
|
64
|
+
self._cwd = cwd.resolve()
|
|
65
|
+
|
|
66
|
+
def get_definition(self) -> ToolDefinition:
|
|
67
|
+
return ToolDefinition(
|
|
68
|
+
name=self.name,
|
|
69
|
+
description=self.description,
|
|
70
|
+
parameters=GrepArguments.model_json_schema(by_alias=True),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def execute(
|
|
74
|
+
self, tool_call_id: str, arguments: dict[str, JsonValue]
|
|
75
|
+
) -> ToolExecutionResult:
|
|
76
|
+
args = GrepArguments.model_validate(arguments)
|
|
77
|
+
root = resolve_to_cwd(args.path, self._cwd)
|
|
78
|
+
if not root.exists():
|
|
79
|
+
raise FileNotFoundError(f"Path not found: {root}")
|
|
80
|
+
command = [find_executable("rg"), "--json", "--line-number", "--color=never", "--hidden", "--sort=path"]
|
|
81
|
+
if args.ignore_case:
|
|
82
|
+
command.append("--ignore-case")
|
|
83
|
+
if args.literal:
|
|
84
|
+
command.append("--fixed-strings")
|
|
85
|
+
if args.glob is not None:
|
|
86
|
+
command.extend(["--glob", args.glob])
|
|
87
|
+
command.extend(["--", args.pattern, str(root)])
|
|
88
|
+
|
|
89
|
+
matches: list[tuple[Path, int, str]] = []
|
|
90
|
+
async with aclosing(command_lines(command, self._cwd, success_codes=(0, 1))) as stream:
|
|
91
|
+
async for raw in stream:
|
|
92
|
+
event = _RgEvent.model_validate_json(raw)
|
|
93
|
+
if event.type != "match":
|
|
94
|
+
continue
|
|
95
|
+
data = event.data
|
|
96
|
+
if data is None or data.path is None or data.lines is None or data.line_number is None:
|
|
97
|
+
raise ValueError("ripgrep returned an invalid match")
|
|
98
|
+
matches.append((Path(data.path.value()), data.line_number, data.lines.value()))
|
|
99
|
+
if len(matches) >= args.limit:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
if not matches:
|
|
103
|
+
return ToolExecutionResult(content=[TextContent(text="No matches found")])
|
|
104
|
+
|
|
105
|
+
output: list[str] = []
|
|
106
|
+
cache: dict[Path, list[str]] = {}
|
|
107
|
+
lines_truncated = False
|
|
108
|
+
output_bytes = 0
|
|
109
|
+
for path, line_number, match_text in matches:
|
|
110
|
+
label = path.relative_to(root).as_posix() if root.is_dir() else path.name
|
|
111
|
+
if args.context:
|
|
112
|
+
if path not in cache:
|
|
113
|
+
text = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="replace")
|
|
114
|
+
cache[path] = text.splitlines()
|
|
115
|
+
lines = cache[path]
|
|
116
|
+
start = max(1, line_number - args.context)
|
|
117
|
+
end = min(len(lines), line_number + args.context)
|
|
118
|
+
else:
|
|
119
|
+
lines = [match_text.rstrip("\r\n")]
|
|
120
|
+
start = end = line_number
|
|
121
|
+
|
|
122
|
+
for number in range(start, end + 1):
|
|
123
|
+
text = lines[number - 1] if args.context else lines[0]
|
|
124
|
+
if len(text) > GREP_MAX_LINE_LENGTH:
|
|
125
|
+
text = text[:GREP_MAX_LINE_LENGTH] + "... [truncated]"
|
|
126
|
+
lines_truncated = True
|
|
127
|
+
separator = ":" if number == line_number else "-"
|
|
128
|
+
row = f"{label}{separator}{number}{separator} {text}"
|
|
129
|
+
output.append(row)
|
|
130
|
+
output_bytes += len(row.encode("utf-8")) + 1
|
|
131
|
+
if output_bytes > DEFAULT_MAX_BYTES:
|
|
132
|
+
break
|
|
133
|
+
if output_bytes > DEFAULT_MAX_BYTES:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
truncation = truncate_text("\n".join(output), max_lines=sys.maxsize)
|
|
137
|
+
text = truncation.content
|
|
138
|
+
details: dict[str, JsonValue] = {}
|
|
139
|
+
notices: list[str] = []
|
|
140
|
+
if len(matches) >= args.limit:
|
|
141
|
+
details["matchLimitReached"] = args.limit
|
|
142
|
+
notices.append(f"{args.limit} matches limit reached. Refine the pattern or increase limit")
|
|
143
|
+
if truncation.truncated:
|
|
144
|
+
details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
|
|
145
|
+
notices.append("50KB output limit reached")
|
|
146
|
+
if lines_truncated:
|
|
147
|
+
details["linesTruncated"] = True
|
|
148
|
+
notices.append("Some lines were truncated to 500 characters. Use read for full lines")
|
|
149
|
+
if notices:
|
|
150
|
+
text += "\n\n[" + ". ".join(notices) + "]"
|
|
151
|
+
return ToolExecutionResult(content=[TextContent(text=text)], details=details)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
6
|
+
|
|
7
|
+
from ...agent.types import ToolExecutionResult
|
|
8
|
+
from ...ai.types import TextContent, ToolDefinition
|
|
9
|
+
from .paths import resolve_to_cwd
|
|
10
|
+
from .truncate import truncate_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LsArguments(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
15
|
+
|
|
16
|
+
path: str = Field(default=".", description="Directory to list.")
|
|
17
|
+
limit: int = Field(default=500, ge=1, description="Maximum entries to return.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LsTool:
|
|
21
|
+
name: str = "ls"
|
|
22
|
+
description: str = (
|
|
23
|
+
"List directory entries alphabetically, including dotfiles. Directories "
|
|
24
|
+
"have a '/' suffix. Defaults to 500 entries, with a 50KB output cap."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def __init__(self, cwd: Path) -> None:
|
|
28
|
+
self._cwd = cwd.resolve()
|
|
29
|
+
|
|
30
|
+
def get_definition(self) -> ToolDefinition:
|
|
31
|
+
return ToolDefinition(
|
|
32
|
+
name=self.name,
|
|
33
|
+
description=self.description,
|
|
34
|
+
parameters=LsArguments.model_json_schema(),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def execute(
|
|
38
|
+
self, tool_call_id: str, arguments: dict[str, JsonValue]
|
|
39
|
+
) -> ToolExecutionResult:
|
|
40
|
+
args = LsArguments.model_validate(arguments)
|
|
41
|
+
path = resolve_to_cwd(args.path, self._cwd)
|
|
42
|
+
return await asyncio.to_thread(self._list, path, args.limit)
|
|
43
|
+
|
|
44
|
+
def _list(self, path: Path, limit: int) -> ToolExecutionResult:
|
|
45
|
+
if not path.is_dir():
|
|
46
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
47
|
+
entries = sorted(path.iterdir(), key=lambda entry: (entry.name.casefold(), entry.name))
|
|
48
|
+
labels = [entry.name + ("/" if entry.is_dir() else "") for entry in entries[:limit]]
|
|
49
|
+
truncation = truncate_text("\n".join(labels), max_lines=sys.maxsize)
|
|
50
|
+
text = truncation.content or "(empty directory)"
|
|
51
|
+
details: dict[str, JsonValue] = {}
|
|
52
|
+
notices: list[str] = []
|
|
53
|
+
if len(entries) > limit:
|
|
54
|
+
details["entryLimitReached"] = limit
|
|
55
|
+
notices.append(f"{limit} entries limit reached. Increase limit for more")
|
|
56
|
+
if truncation.truncated:
|
|
57
|
+
details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
|
|
58
|
+
notices.append("50KB output limit reached")
|
|
59
|
+
if notices:
|
|
60
|
+
text += "\n\n[" + ". ".join(notices) + "]"
|
|
61
|
+
return ToolExecutionResult(content=[TextContent(text=text)], details=details)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from _thread import LockType
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _Entry:
|
|
12
|
+
lock: LockType = field(default_factory=Lock)
|
|
13
|
+
users: int = 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_entries: dict[str, _Entry] = {}
|
|
17
|
+
_registry_lock = Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def file_mutation(path: Path) -> Iterator[None]:
|
|
22
|
+
"""Serialize writes to one path inside the actual worker thread.
|
|
23
|
+
|
|
24
|
+
Cancelling the awaiting coroutine cannot release this lock while the
|
|
25
|
+
filesystem operation is still running. Different paths use different locks.
|
|
26
|
+
"""
|
|
27
|
+
key = os.path.normcase(str(path.resolve()))
|
|
28
|
+
with _registry_lock:
|
|
29
|
+
entry = _entries.setdefault(key, _Entry())
|
|
30
|
+
entry.users += 1
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with entry.lock:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
with _registry_lock:
|
|
37
|
+
entry.users -= 1
|
|
38
|
+
if entry.users == 0:
|
|
39
|
+
del _entries[key]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def resolve_to_cwd(value: str, cwd: Path) -> Path:
|
|
5
|
+
"""Resolve model-supplied paths against the tool's working directory."""
|
|
6
|
+
if value.startswith("@"):
|
|
7
|
+
value = value[1:]
|
|
8
|
+
path = Path(value).expanduser()
|
|
9
|
+
if not path.is_absolute():
|
|
10
|
+
path = cwd / path
|
|
11
|
+
return path.resolve()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_executable(name: str) -> str:
|
|
12
|
+
"""Use PATH or pi's already-installed helpers; never download executables."""
|
|
13
|
+
found = shutil.which(name)
|
|
14
|
+
if found:
|
|
15
|
+
return found
|
|
16
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
17
|
+
bundled = Path.home() / ".pi" / "agent" / "bin" / f"{name}{suffix}"
|
|
18
|
+
if bundled.is_file():
|
|
19
|
+
return str(bundled)
|
|
20
|
+
raise FileNotFoundError(f"{name} is required. Install it and add it to PATH.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def spawn_process(
|
|
24
|
+
command: list[str],
|
|
25
|
+
cwd: Path,
|
|
26
|
+
*,
|
|
27
|
+
merge_stderr: bool = False,
|
|
28
|
+
output: BinaryIO | None = None,
|
|
29
|
+
) -> asyncio.subprocess.Process:
|
|
30
|
+
return await asyncio.create_subprocess_exec(
|
|
31
|
+
*command,
|
|
32
|
+
cwd=cwd,
|
|
33
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
34
|
+
stdout=output if output is not None else asyncio.subprocess.PIPE,
|
|
35
|
+
stderr=(asyncio.subprocess.STDOUT if merge_stderr else asyncio.subprocess.PIPE),
|
|
36
|
+
start_new_session=os.name != "nt",
|
|
37
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
|
38
|
+
limit=8 * 1024 * 1024,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def stop_process(process: asyncio.subprocess.Process) -> None:
|
|
43
|
+
"""Stop a running command and its process tree on cancellation or timeout."""
|
|
44
|
+
if process.returncode is not None:
|
|
45
|
+
return
|
|
46
|
+
if os.name == "nt":
|
|
47
|
+
killer = await asyncio.create_subprocess_exec(
|
|
48
|
+
"taskkill", "/PID", str(process.pid), "/T", "/F",
|
|
49
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
50
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
51
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
52
|
+
)
|
|
53
|
+
await killer.wait()
|
|
54
|
+
if process.returncode is None:
|
|
55
|
+
try:
|
|
56
|
+
process.kill()
|
|
57
|
+
except ProcessLookupError:
|
|
58
|
+
pass
|
|
59
|
+
else:
|
|
60
|
+
try:
|
|
61
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
62
|
+
except ProcessLookupError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def command_lines(
|
|
67
|
+
command: list[str],
|
|
68
|
+
cwd: Path,
|
|
69
|
+
*,
|
|
70
|
+
success_codes: tuple[int, ...] = (0,),
|
|
71
|
+
) -> AsyncIterator[str]:
|
|
72
|
+
"""Stream search output; closing the generator also reaps its child process."""
|
|
73
|
+
process = await spawn_process(command, cwd)
|
|
74
|
+
stderr = bytearray()
|
|
75
|
+
|
|
76
|
+
async def drain_errors() -> None:
|
|
77
|
+
if process.stderr is None:
|
|
78
|
+
return
|
|
79
|
+
while chunk := await process.stderr.read(8192):
|
|
80
|
+
if len(stderr) < 50 * 1024:
|
|
81
|
+
stderr.extend(chunk[:50 * 1024 - len(stderr)])
|
|
82
|
+
|
|
83
|
+
error_task = asyncio.create_task(drain_errors())
|
|
84
|
+
try:
|
|
85
|
+
if process.stdout is None:
|
|
86
|
+
raise RuntimeError("The process has no stdout pipe")
|
|
87
|
+
async for line in process.stdout:
|
|
88
|
+
yield line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
89
|
+
code = await process.wait()
|
|
90
|
+
await error_task
|
|
91
|
+
if code not in success_codes:
|
|
92
|
+
message = stderr.decode("utf-8", errors="replace").strip()
|
|
93
|
+
raise RuntimeError(message or f"Command exited with code {code}")
|
|
94
|
+
finally:
|
|
95
|
+
await stop_process(process)
|
|
96
|
+
if process.stdout is not None:
|
|
97
|
+
# A killed child may leave unread bytes in a paused pipe. Drain them
|
|
98
|
+
# before waiting for asyncio's process transport to close.
|
|
99
|
+
await process.stdout.read()
|
|
100
|
+
await process.wait()
|
|
101
|
+
if not error_task.done():
|
|
102
|
+
error_task.cancel()
|
|
103
|
+
await asyncio.gather(error_task, return_exceptions=True)
|