yycode 0.3.2__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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
tools/apply_patch.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Apply reviewable patches safely inside the workspace."""
|
|
2
|
+
|
|
3
|
+
from difflib import unified_diff
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .read_file import safe_path, workspace_for
|
|
9
|
+
from .safety import ApprovalRequired, approval_required
|
|
10
|
+
|
|
11
|
+
MAX_PATCH_CHARS = 100_000
|
|
12
|
+
MAX_REPLACEMENT_LINES = 80
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strip_fence(patch: str) -> str:
|
|
16
|
+
text = patch
|
|
17
|
+
if text.lstrip().startswith("```"):
|
|
18
|
+
lines = text.strip().splitlines()
|
|
19
|
+
if lines and lines[-1].strip() == "```":
|
|
20
|
+
return "\n".join(lines[1:-1]) + "\n"
|
|
21
|
+
return text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _changed_paths(patch: str) -> set[str]:
|
|
25
|
+
paths = set()
|
|
26
|
+
for line in patch.splitlines():
|
|
27
|
+
if line.startswith("deleted file mode") or line.startswith("+++ /dev/null"):
|
|
28
|
+
raise ApprovalRequired(
|
|
29
|
+
approval_required(
|
|
30
|
+
action="delete_file",
|
|
31
|
+
reason="apply_patch does not delete files without explicit approval.",
|
|
32
|
+
risk="File deletion can remove user work or project assets.",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
if line.startswith(("--- ", "+++ ")):
|
|
36
|
+
raw = line[4:].split("\t", 1)[0].strip()
|
|
37
|
+
if raw == "/dev/null":
|
|
38
|
+
continue
|
|
39
|
+
if raw.startswith(("a/", "b/")):
|
|
40
|
+
raw = raw[2:]
|
|
41
|
+
paths.add(raw)
|
|
42
|
+
elif line.startswith("diff --git "):
|
|
43
|
+
match = re.match(r"diff --git a/(.+?) b/(.+)$", line)
|
|
44
|
+
if match:
|
|
45
|
+
paths.update(match.groups())
|
|
46
|
+
return paths
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_paths(paths: set[str], workdir: Path | str | None = None) -> None:
|
|
50
|
+
if not paths:
|
|
51
|
+
raise ValueError("no changed paths found in patch")
|
|
52
|
+
for path in paths:
|
|
53
|
+
if Path(path).is_absolute() or ".." in Path(path).parts:
|
|
54
|
+
raise ValueError(f"path escapes workspace: {path}")
|
|
55
|
+
safe_path(path, workdir)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_snapshot(path: str, workdir: Path | str | None = None) -> str:
|
|
59
|
+
fp = safe_path(path, workdir)
|
|
60
|
+
if not fp.exists():
|
|
61
|
+
return ""
|
|
62
|
+
try:
|
|
63
|
+
return fp.read_text()
|
|
64
|
+
except UnicodeDecodeError:
|
|
65
|
+
return "<binary or non-utf8 file>"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_operation_diff(
|
|
69
|
+
action: str,
|
|
70
|
+
before_by_path: dict[str, str],
|
|
71
|
+
workdir: Path | str | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
sections = _diff_sections(
|
|
74
|
+
{
|
|
75
|
+
path: (before, _read_snapshot(path, workdir))
|
|
76
|
+
for path, before in before_by_path.items()
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
if not sections:
|
|
80
|
+
return f"{action}\n\ndiff: No diff."
|
|
81
|
+
return f"{action}\n\ndiff:\n" + "\n".join(sections)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _diff_sections(before_after_by_path: dict[str, tuple[str, str]]) -> list[str]:
|
|
85
|
+
sections = []
|
|
86
|
+
for path, (before, after) in before_after_by_path.items():
|
|
87
|
+
if before == after:
|
|
88
|
+
continue
|
|
89
|
+
before_lines = before.splitlines()
|
|
90
|
+
after_lines = after.splitlines()
|
|
91
|
+
diff = "\n".join(
|
|
92
|
+
unified_diff(
|
|
93
|
+
before_lines,
|
|
94
|
+
after_lines,
|
|
95
|
+
fromfile=f"a/{path}",
|
|
96
|
+
tofile=f"b/{path}",
|
|
97
|
+
lineterm="",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
if diff:
|
|
101
|
+
sections.append(diff)
|
|
102
|
+
return sections
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_preview_diff(before_after_by_path: dict[str, tuple[str, str]]) -> str:
|
|
106
|
+
sections = _diff_sections(before_after_by_path)
|
|
107
|
+
return "\n".join(sections)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _preview_replacement(
|
|
111
|
+
path: str,
|
|
112
|
+
old_text: str,
|
|
113
|
+
new_text: str,
|
|
114
|
+
workdir: Path | str | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
try:
|
|
117
|
+
fp = safe_path(path, workdir)
|
|
118
|
+
content = fp.read_text()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
return f"Error: {exc}"
|
|
121
|
+
if old_text not in content:
|
|
122
|
+
return f"Error: old_text not found in {path}"
|
|
123
|
+
after = content.replace(old_text, new_text, 1)
|
|
124
|
+
return _format_preview_diff({path: (content, after)})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def preview_apply_patch_diff(
|
|
128
|
+
patch: str = "",
|
|
129
|
+
path: str = "",
|
|
130
|
+
old_text: str = "",
|
|
131
|
+
new_text: str = "",
|
|
132
|
+
workdir: Path | str | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Return the diff that apply_patch would apply without modifying files."""
|
|
135
|
+
if path or old_text or new_text:
|
|
136
|
+
if not path or not old_text:
|
|
137
|
+
return ""
|
|
138
|
+
return _preview_replacement(path, old_text, new_text, workdir)
|
|
139
|
+
|
|
140
|
+
patch_text = _strip_fence(patch)
|
|
141
|
+
if not patch_text.strip() or patch_text.lstrip().startswith("*** Begin Patch"):
|
|
142
|
+
return ""
|
|
143
|
+
if len(patch_text) > MAX_PATCH_CHARS:
|
|
144
|
+
return ""
|
|
145
|
+
try:
|
|
146
|
+
changed_paths = _changed_paths(patch_text)
|
|
147
|
+
_validate_paths(changed_paths, workdir)
|
|
148
|
+
except Exception:
|
|
149
|
+
return ""
|
|
150
|
+
return patch_text.strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _looks_like_whole_file(content: str, old_text: str) -> bool:
|
|
154
|
+
return old_text == content and len(content.splitlines()) > MAX_REPLACEMENT_LINES
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _replacement_is_too_large(old_text: str, new_text: str) -> bool:
|
|
158
|
+
return (
|
|
159
|
+
len(old_text.splitlines()) > MAX_REPLACEMENT_LINES
|
|
160
|
+
or len(new_text.splitlines()) > MAX_REPLACEMENT_LINES
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _apply_replacement(
|
|
165
|
+
path: str,
|
|
166
|
+
old_text: str,
|
|
167
|
+
new_text: str,
|
|
168
|
+
workdir: Path | str | None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
fp = safe_path(path, workdir)
|
|
171
|
+
content = fp.read_text()
|
|
172
|
+
if old_text not in content:
|
|
173
|
+
return f"Error: old_text not found in {path}"
|
|
174
|
+
if _looks_like_whole_file(content, old_text):
|
|
175
|
+
return (
|
|
176
|
+
"Error: Refusing whole-file replacement in apply_patch exact replacement mode. "
|
|
177
|
+
"Use old_text/new_text for only the smallest changed block, or provide a focused unified diff."
|
|
178
|
+
)
|
|
179
|
+
if _replacement_is_too_large(old_text, new_text):
|
|
180
|
+
return (
|
|
181
|
+
f"Error: Replacement block is too large for exact replacement mode "
|
|
182
|
+
f"({MAX_REPLACEMENT_LINES} line limit). Use a focused unified diff with context instead."
|
|
183
|
+
)
|
|
184
|
+
before_by_path = {path: content}
|
|
185
|
+
fp.write_text(content.replace(old_text, new_text, 1))
|
|
186
|
+
return _format_operation_diff(f"Applied replacement patch to {path}.", before_by_path, workdir)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _edit_approval_required(paths: list[str]) -> str:
|
|
190
|
+
path_text = ", ".join(paths) if paths else ""
|
|
191
|
+
return approval_required(
|
|
192
|
+
action="edit_file",
|
|
193
|
+
path=path_text,
|
|
194
|
+
reason="apply_patch edits workspace files and requires user approval before writing.",
|
|
195
|
+
risk="File edits can overwrite user work or introduce unintended code changes.",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def apply_patch(
|
|
200
|
+
patch: str = "",
|
|
201
|
+
path: str = "",
|
|
202
|
+
old_text: str = "",
|
|
203
|
+
new_text: str = "",
|
|
204
|
+
approved: bool = False,
|
|
205
|
+
workdir: Path | str | None = None,
|
|
206
|
+
) -> str:
|
|
207
|
+
"""Apply a unified diff or exact replacement patch after path validation."""
|
|
208
|
+
try:
|
|
209
|
+
workspace = workspace_for(workdir)
|
|
210
|
+
if path or old_text or new_text:
|
|
211
|
+
if not path or not old_text:
|
|
212
|
+
return "Error: path and old_text are required for replacement patches"
|
|
213
|
+
if not approved:
|
|
214
|
+
return _edit_approval_required([path])
|
|
215
|
+
return _apply_replacement(path, old_text, new_text, workspace.root)
|
|
216
|
+
|
|
217
|
+
patch_text = _strip_fence(patch)
|
|
218
|
+
if not patch_text.strip():
|
|
219
|
+
return "Error: Patch is empty"
|
|
220
|
+
if patch_text.lstrip().startswith("*** Begin Patch"):
|
|
221
|
+
return "Error: apply_patch expects a unified diff patch, not Begin Patch format"
|
|
222
|
+
if len(patch_text) > MAX_PATCH_CHARS:
|
|
223
|
+
return f"Error: Patch exceeds {MAX_PATCH_CHARS} characters"
|
|
224
|
+
|
|
225
|
+
changed_paths = _changed_paths(patch_text)
|
|
226
|
+
_validate_paths(changed_paths, workspace.root)
|
|
227
|
+
if not approved:
|
|
228
|
+
return _edit_approval_required(sorted(changed_paths))
|
|
229
|
+
before_by_path = {path: _read_snapshot(path, workspace.root) for path in sorted(changed_paths)}
|
|
230
|
+
|
|
231
|
+
check = subprocess.run(
|
|
232
|
+
["git", "apply", "--check", "--whitespace=nowarn", "-"],
|
|
233
|
+
input=patch_text,
|
|
234
|
+
cwd=workspace.root,
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
errors="backslashreplace",
|
|
238
|
+
timeout=60,
|
|
239
|
+
)
|
|
240
|
+
if check.returncode != 0:
|
|
241
|
+
output = (check.stdout + check.stderr).strip()
|
|
242
|
+
return f"Error: {output or 'git apply --check failed'}"
|
|
243
|
+
|
|
244
|
+
result = subprocess.run(
|
|
245
|
+
["git", "apply", "--whitespace=nowarn", "-"],
|
|
246
|
+
input=patch_text,
|
|
247
|
+
cwd=workspace.root,
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
errors="backslashreplace",
|
|
251
|
+
timeout=60,
|
|
252
|
+
)
|
|
253
|
+
if result.returncode != 0:
|
|
254
|
+
output = (result.stdout + result.stderr).strip()
|
|
255
|
+
return f"Error: {output or 'git apply failed'}"
|
|
256
|
+
|
|
257
|
+
return _format_operation_diff("Applied patch.", before_by_path, workspace.root)
|
|
258
|
+
except subprocess.TimeoutExpired:
|
|
259
|
+
return "Error: Timeout"
|
|
260
|
+
except ApprovalRequired as exc:
|
|
261
|
+
return str(exc)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
return f"Error: {exc}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
apply_patch_tool = {
|
|
267
|
+
"name": "apply_patch",
|
|
268
|
+
"description": (
|
|
269
|
+
"Primary tool for editing existing files. Prefer path + old_text + new_text "
|
|
270
|
+
"for exact replacements (old_text MUST contain ONLY the exact lines to be changed, "
|
|
271
|
+
"NOT the entire file and not large unchanged blocks); use patch for focused unified diffs. Requires approved=true "
|
|
272
|
+
"after explicit user approval and returns the resulting diff."
|
|
273
|
+
),
|
|
274
|
+
"execution": {
|
|
275
|
+
"side_effects": "workspace_write",
|
|
276
|
+
"concurrency": "serial",
|
|
277
|
+
"timeout_seconds": 60,
|
|
278
|
+
},
|
|
279
|
+
"input_schema": {
|
|
280
|
+
"type": "object",
|
|
281
|
+
"properties": {
|
|
282
|
+
"patch": {
|
|
283
|
+
"type": "string",
|
|
284
|
+
"description": "Optional unified diff patch to apply inside the workspace.",
|
|
285
|
+
},
|
|
286
|
+
"path": {
|
|
287
|
+
"type": "string",
|
|
288
|
+
"description": "Workspace-relative file path for exact replacement mode.",
|
|
289
|
+
},
|
|
290
|
+
"old_text": {
|
|
291
|
+
"type": "string",
|
|
292
|
+
"description": "Small exact text block to replace once. Do not pass the whole file or large unchanged blocks.",
|
|
293
|
+
},
|
|
294
|
+
"new_text": {
|
|
295
|
+
"type": "string",
|
|
296
|
+
"description": "Replacement text for the small changed block.",
|
|
297
|
+
},
|
|
298
|
+
"approved": {
|
|
299
|
+
"type": "boolean",
|
|
300
|
+
"description": "Set true only after the user explicitly approves this file edit.",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
"required": [],
|
|
304
|
+
},
|
|
305
|
+
}
|
tools/bash.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Bash tool."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .safety import unsafe_command_response
|
|
7
|
+
from .read_file import workspace_for
|
|
8
|
+
|
|
9
|
+
MAX_OUTPUT_CHARS = 50_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_stream(name: str, content: str) -> str:
|
|
13
|
+
"""Format a subprocess stream with an explicit empty marker."""
|
|
14
|
+
text = content.strip()
|
|
15
|
+
return f"{name}:\n{text or '(empty)'}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_bash_result(returncode: int, stdout: str, stderr: str) -> str:
|
|
19
|
+
"""Return a model-readable command result."""
|
|
20
|
+
status = "success" if returncode == 0 else "failed"
|
|
21
|
+
result = (
|
|
22
|
+
f"status: {status}\n"
|
|
23
|
+
f"exit_code: {returncode}\n"
|
|
24
|
+
f"{_format_stream('stdout', stdout)}\n"
|
|
25
|
+
f"{_format_stream('stderr', stderr)}"
|
|
26
|
+
)
|
|
27
|
+
if len(result) > MAX_OUTPUT_CHARS:
|
|
28
|
+
return result[:MAX_OUTPUT_CHARS] + f"\n... output truncated to {MAX_OUTPUT_CHARS} chars"
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bash(command: str, approved: bool = False, workdir: Path | str | None = None) -> str:
|
|
33
|
+
"""Run a shell command."""
|
|
34
|
+
unsafe_response = unsafe_command_response(command)
|
|
35
|
+
if unsafe_response and not approved:
|
|
36
|
+
return unsafe_response
|
|
37
|
+
try:
|
|
38
|
+
workspace = workspace_for(workdir)
|
|
39
|
+
r = subprocess.run(
|
|
40
|
+
command,
|
|
41
|
+
shell=True,
|
|
42
|
+
cwd=workspace.root,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
errors="backslashreplace",
|
|
46
|
+
timeout=120,
|
|
47
|
+
)
|
|
48
|
+
return _format_bash_result(r.returncode, r.stdout, r.stderr)
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
return "Error: Timeout (120s)"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
bash_tool = {
|
|
54
|
+
"name": "bash",
|
|
55
|
+
"description": (
|
|
56
|
+
"Run a shell command when built-in tools are insufficient. "
|
|
57
|
+
"Do not use this for normal code navigation when list_files, grep, read_file, "
|
|
58
|
+
"read_many_files, git_show, or git_diff can answer the question."
|
|
59
|
+
),
|
|
60
|
+
"execution": {
|
|
61
|
+
"side_effects": "process",
|
|
62
|
+
"concurrency": "serial",
|
|
63
|
+
"timeout_seconds": 130,
|
|
64
|
+
},
|
|
65
|
+
"input_schema": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"command": {"type": "string"},
|
|
69
|
+
"approved": {
|
|
70
|
+
"type": "boolean",
|
|
71
|
+
"description": "Set true only after this command is approved by runtime approval.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"required": ["command"],
|
|
75
|
+
},
|
|
76
|
+
}
|
tools/diff_utils.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Helpers for rendering git diffs after workspace writes."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import read_file
|
|
7
|
+
|
|
8
|
+
MAX_DIFF_CHARS = 12_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _workspace(workdir: Path | str | None = None):
|
|
12
|
+
return read_file.workspace_for(workdir)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _relative_paths(paths: list[str] | None, workdir: Path | str | None = None) -> list[str]:
|
|
16
|
+
if not paths:
|
|
17
|
+
return []
|
|
18
|
+
workspace = _workspace(workdir)
|
|
19
|
+
return [str(workspace.safe_path(path).relative_to(workspace.root)) for path in paths]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def git_diff_stat(paths: list[str] | None = None, workdir: Path | str | None = None) -> str:
|
|
23
|
+
"""Return git diff stat for optional workspace-relative paths."""
|
|
24
|
+
return _run_git_diff(["--stat"], paths, max_chars=4_000, workdir=workdir)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def git_diff_preview(
|
|
28
|
+
paths: list[str] | None = None,
|
|
29
|
+
max_chars: int = MAX_DIFF_CHARS,
|
|
30
|
+
workdir: Path | str | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Return a capped git diff for optional workspace-relative paths."""
|
|
33
|
+
return _run_git_diff([], paths, max_chars=max_chars, workdir=workdir)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_diff_result(
|
|
37
|
+
action: str,
|
|
38
|
+
paths: list[str] | None = None,
|
|
39
|
+
workdir: Path | str | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Format a write-tool result with diff stat and capped diff preview."""
|
|
42
|
+
stat = git_diff_stat(paths, workdir=workdir)
|
|
43
|
+
diff = git_diff_preview(paths, workdir=workdir)
|
|
44
|
+
if not diff and paths:
|
|
45
|
+
stat = stat or _untracked_files_stat(paths, workdir=workdir)
|
|
46
|
+
diff = _untracked_files_diff(paths, workdir=workdir)
|
|
47
|
+
parts = [action]
|
|
48
|
+
if stat:
|
|
49
|
+
parts.append(f"diff_stat:\n{stat}")
|
|
50
|
+
if diff:
|
|
51
|
+
parts.append(f"diff:\n{diff}")
|
|
52
|
+
if len(parts) == 1:
|
|
53
|
+
parts.append("diff: No diff.")
|
|
54
|
+
return "\n\n".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_git_diff(
|
|
58
|
+
args: list[str],
|
|
59
|
+
paths: list[str] | None,
|
|
60
|
+
max_chars: int,
|
|
61
|
+
workdir: Path | str | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
workspace = _workspace(workdir)
|
|
64
|
+
command = ["git", "diff", *args, "--", *_relative_paths(paths, workdir)]
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
command,
|
|
67
|
+
cwd=workspace.root,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
errors="backslashreplace",
|
|
71
|
+
timeout=30,
|
|
72
|
+
)
|
|
73
|
+
output = (result.stdout + result.stderr).strip()
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
return f"Error: {output or f'git diff exited with {result.returncode}'}"
|
|
76
|
+
if len(output) > max_chars:
|
|
77
|
+
return output[:max_chars] + f"\n... diff truncated to {max_chars} chars"
|
|
78
|
+
return output
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _untracked_files_diff(paths: list[str], workdir: Path | str | None = None) -> str:
|
|
82
|
+
workspace = _workspace(workdir)
|
|
83
|
+
sections = []
|
|
84
|
+
for path in paths:
|
|
85
|
+
file_path = workspace.safe_path(path)
|
|
86
|
+
if not file_path.exists() or _is_tracked(path, workdir=workdir):
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
text = file_path.read_text()
|
|
90
|
+
except UnicodeDecodeError:
|
|
91
|
+
text = "<binary or non-utf8 file>"
|
|
92
|
+
relative_path = file_path.relative_to(workspace.root)
|
|
93
|
+
lines = text.splitlines()
|
|
94
|
+
sections.append(
|
|
95
|
+
"\n".join(
|
|
96
|
+
[
|
|
97
|
+
f"diff --git a/{relative_path} b/{relative_path}",
|
|
98
|
+
"new file mode 100644",
|
|
99
|
+
"--- /dev/null",
|
|
100
|
+
f"+++ b/{relative_path}",
|
|
101
|
+
f"@@ -0,0 +1,{len(lines)} @@",
|
|
102
|
+
*[f"+{line}" for line in lines],
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
output = "\n".join(sections)
|
|
107
|
+
if len(output) > MAX_DIFF_CHARS:
|
|
108
|
+
return output[:MAX_DIFF_CHARS] + f"\n... diff truncated to {MAX_DIFF_CHARS} chars"
|
|
109
|
+
return output
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _untracked_files_stat(paths: list[str], workdir: Path | str | None = None) -> str:
|
|
113
|
+
workspace = _workspace(workdir)
|
|
114
|
+
stats = []
|
|
115
|
+
for path in paths:
|
|
116
|
+
file_path = workspace.safe_path(path)
|
|
117
|
+
if not file_path.exists() or _is_tracked(path, workdir=workdir):
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
line_count = len(file_path.read_text().splitlines())
|
|
121
|
+
except UnicodeDecodeError:
|
|
122
|
+
line_count = 0
|
|
123
|
+
relative_path = file_path.relative_to(workspace.root)
|
|
124
|
+
stats.append(f" {relative_path} | {line_count} +")
|
|
125
|
+
return "\n".join(stats)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_tracked(path: str, workdir: Path | str | None = None) -> bool:
|
|
129
|
+
workspace = _workspace(workdir)
|
|
130
|
+
relative_path = str(workspace.safe_path(path).relative_to(workspace.root))
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["git", "ls-files", "--error-unmatch", "--", relative_path],
|
|
133
|
+
cwd=workspace.root,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
errors="backslashreplace",
|
|
137
|
+
timeout=30,
|
|
138
|
+
)
|
|
139
|
+
return result.returncode == 0
|
tools/edit_file.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Edit file tool."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def edit_file(
|
|
7
|
+
path: str,
|
|
8
|
+
old_text: str,
|
|
9
|
+
new_text: str,
|
|
10
|
+
workdir: Path | str | None = None,
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Replace exact text in file."""
|
|
13
|
+
return (
|
|
14
|
+
f"Code workflow guard blocked edit_file for: {path}\n\n"
|
|
15
|
+
"Use apply_patch with path + old_text + new_text, or a unified diff, "
|
|
16
|
+
"for code edits so the change is reviewable and the diff can be shown to the user."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
edit_file_tool = {
|
|
21
|
+
"name": "edit_file",
|
|
22
|
+
"description": (
|
|
23
|
+
"Fallback exact-text replacement for exceptional cases. Prefer apply_patch "
|
|
24
|
+
"for normal code edits so changes are reviewable as a diff."
|
|
25
|
+
),
|
|
26
|
+
"execution": {
|
|
27
|
+
"side_effects": "workspace_write",
|
|
28
|
+
"concurrency": "serial",
|
|
29
|
+
"timeout_seconds": 60,
|
|
30
|
+
},
|
|
31
|
+
"input_schema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"path": {"type": "string"},
|
|
35
|
+
"old_text": {"type": "string"},
|
|
36
|
+
"new_text": {"type": "string"},
|
|
37
|
+
},
|
|
38
|
+
"required": ["path", "old_text", "new_text"],
|
|
39
|
+
},
|
|
40
|
+
}
|
tools/git_diff.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Git diff inspection tool."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .read_file import workspace_for
|
|
7
|
+
|
|
8
|
+
MAX_OUTPUT_CHARS = 50_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _relative_path(path: str, workdir: Path | str | None = None) -> str:
|
|
12
|
+
workspace = workspace_for(workdir)
|
|
13
|
+
return str(workspace.safe_path(path).relative_to(workspace.root))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def git_diff(
|
|
17
|
+
paths: list[str] | None = None,
|
|
18
|
+
staged: bool = False,
|
|
19
|
+
workdir: Path | str | None = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Return git diff for workspace-relative paths."""
|
|
22
|
+
try:
|
|
23
|
+
workspace = workspace_for(workdir)
|
|
24
|
+
command = ["git", "diff"]
|
|
25
|
+
if staged:
|
|
26
|
+
command.append("--cached")
|
|
27
|
+
command.append("--")
|
|
28
|
+
if paths:
|
|
29
|
+
command.extend(_relative_path(path, workdir) for path in paths)
|
|
30
|
+
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
command,
|
|
33
|
+
cwd=workspace.root,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
errors="backslashreplace",
|
|
37
|
+
timeout=30,
|
|
38
|
+
)
|
|
39
|
+
output = (result.stdout + result.stderr).strip()
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
return f"Error: {output or f'git diff exited with {result.returncode}'}"
|
|
42
|
+
return output[:MAX_OUTPUT_CHARS] if output else "No diff."
|
|
43
|
+
except subprocess.TimeoutExpired:
|
|
44
|
+
return "Error: Timeout (30s)"
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
return f"Error: {exc}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
git_diff_tool = {
|
|
50
|
+
"name": "git_diff",
|
|
51
|
+
"description": "Show the current git diff, optionally scoped to workspace-relative paths.",
|
|
52
|
+
"execution": {
|
|
53
|
+
"side_effects": "read_only",
|
|
54
|
+
"concurrency": "safe",
|
|
55
|
+
"timeout_seconds": 30,
|
|
56
|
+
},
|
|
57
|
+
"input_schema": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"paths": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {"type": "string"},
|
|
63
|
+
"description": "Optional workspace-relative paths to diff.",
|
|
64
|
+
},
|
|
65
|
+
"staged": {
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"description": "When true, show staged diff with git diff --cached.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"required": [],
|
|
71
|
+
},
|
|
72
|
+
}
|
tools/git_show.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Git show inspection tool."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import read_file
|
|
7
|
+
|
|
8
|
+
MAX_OUTPUT_CHARS = 50_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _relative_path(path: str, workdir: Path | str | None = None) -> str:
|
|
12
|
+
workspace = read_file.workspace_for(workdir)
|
|
13
|
+
return str(workspace.safe_path(path).relative_to(workspace.root))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def git_show(ref: str = "HEAD", path: str = "", workdir: Path | str | None = None) -> str:
|
|
17
|
+
"""Show a git object or a file at a git ref."""
|
|
18
|
+
try:
|
|
19
|
+
workspace = read_file.workspace_for(workdir)
|
|
20
|
+
command = ["git", "show", "--no-ext-diff", "--color=never"]
|
|
21
|
+
if path:
|
|
22
|
+
command.append(f"{ref}:{_relative_path(path, workdir)}")
|
|
23
|
+
else:
|
|
24
|
+
command.append(ref)
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
command,
|
|
27
|
+
cwd=workspace.root,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
errors="backslashreplace",
|
|
31
|
+
timeout=30,
|
|
32
|
+
)
|
|
33
|
+
output = (result.stdout + result.stderr).strip()
|
|
34
|
+
if result.returncode != 0:
|
|
35
|
+
return f"Error: {output or f'git show exited with {result.returncode}'}"
|
|
36
|
+
return output[:MAX_OUTPUT_CHARS] if output else "(no output)"
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
return "Error: Timeout (30s)"
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
return f"Error: {exc}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
git_show_tool = {
|
|
44
|
+
"name": "git_show",
|
|
45
|
+
"description": "Show git commit content or a workspace file at a specific ref.",
|
|
46
|
+
"execution": {
|
|
47
|
+
"side_effects": "read_only",
|
|
48
|
+
"concurrency": "safe",
|
|
49
|
+
"timeout_seconds": 30,
|
|
50
|
+
},
|
|
51
|
+
"input_schema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"ref": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Git ref, commit, tag, or range. Defaults to HEAD.",
|
|
57
|
+
},
|
|
58
|
+
"path": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Optional workspace-relative path to show at the ref.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"required": [],
|
|
64
|
+
},
|
|
65
|
+
}
|