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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Model-facing compaction for verbose tool outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from agent.runtime.tool_events import file_paths_for_tool_call
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MAX_MODEL_TOOL_OUTPUT_CHARS = 4_000
|
|
12
|
+
MAX_MODEL_READ_OUTPUT_CHARS = 12_000
|
|
13
|
+
MAX_MODEL_DIFF_LINES = 80
|
|
14
|
+
MAX_MODEL_COMMAND_OUTPUT_CHARS = 3_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ToolOutputView:
|
|
19
|
+
"""Separate output shown to users from output persisted into model messages."""
|
|
20
|
+
|
|
21
|
+
display: str
|
|
22
|
+
model: str
|
|
23
|
+
context_policy: str = "full"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_tool_output_view(tool_name: str, raw_output: str, tc) -> ToolOutputView:
|
|
27
|
+
"""Return display and model-facing representations for one tool result."""
|
|
28
|
+
display = raw_output or ""
|
|
29
|
+
if tool_name == "git_diff":
|
|
30
|
+
return ToolOutputView(
|
|
31
|
+
display=display,
|
|
32
|
+
model=_compact_diff_output(display, "git_diff"),
|
|
33
|
+
context_policy="compact",
|
|
34
|
+
)
|
|
35
|
+
if tool_name in {"apply_patch", "write_file"}:
|
|
36
|
+
return ToolOutputView(
|
|
37
|
+
display=display,
|
|
38
|
+
model=_compact_write_output(tool_name, display, tc),
|
|
39
|
+
context_policy="compact",
|
|
40
|
+
)
|
|
41
|
+
if tool_name in {"bash", "verify"}:
|
|
42
|
+
return _command_output_view(tool_name, display)
|
|
43
|
+
if tool_name in {"read_file", "read_many_files", "grep", "git_show"}:
|
|
44
|
+
return _read_output_view(tool_name, display, tc)
|
|
45
|
+
if len(display) > MAX_MODEL_TOOL_OUTPUT_CHARS:
|
|
46
|
+
return ToolOutputView(
|
|
47
|
+
display=display,
|
|
48
|
+
model=_truncate_with_notice(display, MAX_MODEL_TOOL_OUTPUT_CHARS),
|
|
49
|
+
context_policy="compact",
|
|
50
|
+
)
|
|
51
|
+
return ToolOutputView(display=display, model=display)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compact_preflight_output(output: str) -> str:
|
|
55
|
+
"""Return a compact preflight block for model context."""
|
|
56
|
+
changed_files = _changed_files_from_workspace_state(output)
|
|
57
|
+
diff_files = _changed_files_from_diff(output)
|
|
58
|
+
files = _unique([*changed_files, *diff_files])
|
|
59
|
+
lines = [
|
|
60
|
+
"Code workflow guard blocked this write because workspace preflight had not been reviewed yet.",
|
|
61
|
+
"",
|
|
62
|
+
"Preflight summary:",
|
|
63
|
+
f"- changed_files: {len(files)}" if files else "- changed_files: unknown",
|
|
64
|
+
]
|
|
65
|
+
lines.extend(f" - {path}" for path in files[:20])
|
|
66
|
+
if len(files) > 20:
|
|
67
|
+
lines.append(f" ... {len(files) - 20} more file(s)")
|
|
68
|
+
lines.extend(
|
|
69
|
+
[
|
|
70
|
+
"",
|
|
71
|
+
"Review workspace_state/git_diff results, then retry the write with the smallest safe patch.",
|
|
72
|
+
"Verbose preflight output was omitted from model context to avoid carrying large diffs forward.",
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _command_output_view(tool_name: str, output: str) -> ToolOutputView:
|
|
79
|
+
if _is_success_empty_command_output(output):
|
|
80
|
+
return ToolOutputView(
|
|
81
|
+
display=output,
|
|
82
|
+
model=(
|
|
83
|
+
"[Tool output omitted from model context; command completed successfully "
|
|
84
|
+
"with empty stdout/stderr. Full result was shown in the UI.]"
|
|
85
|
+
),
|
|
86
|
+
context_policy="marker",
|
|
87
|
+
)
|
|
88
|
+
if len(output) > MAX_MODEL_COMMAND_OUTPUT_CHARS:
|
|
89
|
+
return ToolOutputView(
|
|
90
|
+
display=output,
|
|
91
|
+
model=_compact_command_output(tool_name, output),
|
|
92
|
+
context_policy="compact",
|
|
93
|
+
)
|
|
94
|
+
return ToolOutputView(display=output, model=output)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_output_view(tool_name: str, output: str, tc) -> ToolOutputView:
|
|
98
|
+
if len(output) <= MAX_MODEL_READ_OUTPUT_CHARS:
|
|
99
|
+
return ToolOutputView(display=output, model=output)
|
|
100
|
+
paths = file_paths_for_tool_call(tc)
|
|
101
|
+
path_text = ", ".join(paths[:10]) if paths else "(unknown)"
|
|
102
|
+
head = output[:5_000].rstrip()
|
|
103
|
+
tail = output[-2_000:].lstrip()
|
|
104
|
+
omitted = max(len(output) - len(head) - len(tail), 0)
|
|
105
|
+
model = (
|
|
106
|
+
f"{tool_name} output was compacted for model context.\n"
|
|
107
|
+
f"paths: {path_text}\n"
|
|
108
|
+
f"original_chars: {len(output)}\n"
|
|
109
|
+
f"omitted_chars: {omitted}\n"
|
|
110
|
+
"Use a narrower read_file line range or grep query if more detail is needed.\n\n"
|
|
111
|
+
f"head:\n{head}\n\n"
|
|
112
|
+
f"tail:\n{tail}"
|
|
113
|
+
)
|
|
114
|
+
return ToolOutputView(display=output, model=model, context_policy="compact")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _compact_write_output(tool_name: str, output: str, tc) -> str:
|
|
118
|
+
if output.startswith(("Error:", "approval_required:", "Code workflow guard blocked")):
|
|
119
|
+
return _truncate_with_notice(output, MAX_MODEL_TOOL_OUTPUT_CHARS)
|
|
120
|
+
paths = file_paths_for_tool_call(tc)
|
|
121
|
+
diff = _extract_diff(output)
|
|
122
|
+
stat = _extract_diff_stat(output)
|
|
123
|
+
lines = [f"{tool_name} completed."]
|
|
124
|
+
if paths:
|
|
125
|
+
lines.append("files:")
|
|
126
|
+
lines.extend(f"- {path}" for path in paths[:20])
|
|
127
|
+
if len(paths) > 20:
|
|
128
|
+
lines.append(f"- ... {len(paths) - 20} more file(s)")
|
|
129
|
+
if stat:
|
|
130
|
+
lines.extend(["", "diff_stat:", stat])
|
|
131
|
+
if diff:
|
|
132
|
+
lines.extend(["", _compact_diff_output(diff, "diff preview")])
|
|
133
|
+
else:
|
|
134
|
+
lines.extend(["", _truncate_with_notice(output, MAX_MODEL_TOOL_OUTPUT_CHARS)])
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compact_diff_output(diff: str, label: str) -> str:
|
|
139
|
+
if not diff.strip() or diff.strip() == "No diff.":
|
|
140
|
+
return "No diff."
|
|
141
|
+
files = _changed_files_from_diff(diff)
|
|
142
|
+
added = removed = 0
|
|
143
|
+
kept_lines: list[str] = []
|
|
144
|
+
for line in diff.splitlines():
|
|
145
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
146
|
+
added += 1
|
|
147
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
148
|
+
removed += 1
|
|
149
|
+
if len(kept_lines) < MAX_MODEL_DIFF_LINES:
|
|
150
|
+
kept_lines.append(line)
|
|
151
|
+
lines = [
|
|
152
|
+
f"{label} summary:",
|
|
153
|
+
f"- files_changed: {len(files)}",
|
|
154
|
+
f"- added_lines: {added}",
|
|
155
|
+
f"- removed_lines: {removed}",
|
|
156
|
+
]
|
|
157
|
+
if files:
|
|
158
|
+
lines.append("- files:")
|
|
159
|
+
lines.extend(f" - {path}" for path in files[:20])
|
|
160
|
+
if len(files) > 20:
|
|
161
|
+
lines.append(f" ... {len(files) - 20} more file(s)")
|
|
162
|
+
lines.extend(["", f"first_{MAX_MODEL_DIFF_LINES}_diff_lines:", *kept_lines])
|
|
163
|
+
if len(diff.splitlines()) > MAX_MODEL_DIFF_LINES:
|
|
164
|
+
lines.append(
|
|
165
|
+
"... diff truncated for model context; full output was streamed to the UI "
|
|
166
|
+
"or can be requested again with git_diff."
|
|
167
|
+
)
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _compact_command_output(tool_name: str, output: str) -> str:
|
|
172
|
+
if len(output) <= MAX_MODEL_COMMAND_OUTPUT_CHARS:
|
|
173
|
+
return output
|
|
174
|
+
head = output[:1200].rstrip()
|
|
175
|
+
tail = output[-1200:].lstrip()
|
|
176
|
+
omitted = len(output) - len(head) - len(tail)
|
|
177
|
+
return (
|
|
178
|
+
f"{tool_name} output was truncated for model context.\n"
|
|
179
|
+
f"original_chars: {len(output)}\n"
|
|
180
|
+
f"omitted_chars: {max(omitted, 0)}\n\n"
|
|
181
|
+
f"head:\n{head}\n\n"
|
|
182
|
+
f"tail:\n{tail}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_success_empty_command_output(output: str) -> bool:
|
|
187
|
+
normalized = output.replace("\r\n", "\n")
|
|
188
|
+
return (
|
|
189
|
+
"status: success" in normalized
|
|
190
|
+
and "exit_code: 0" in normalized
|
|
191
|
+
and "stdout:\n(empty)" in normalized
|
|
192
|
+
and "stderr:\n(empty)" in normalized
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _truncate_with_notice(text: str, limit: int) -> str:
|
|
197
|
+
if len(text) <= limit:
|
|
198
|
+
return text
|
|
199
|
+
return (
|
|
200
|
+
text[:limit].rstrip()
|
|
201
|
+
+ f"\n... output truncated for model context from {len(text)} to {limit} chars"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _extract_diff(output: str) -> str:
|
|
206
|
+
marker = "\ndiff:\n"
|
|
207
|
+
if marker in output:
|
|
208
|
+
return output.split(marker, 1)[1]
|
|
209
|
+
if output.startswith("diff:\n"):
|
|
210
|
+
return output[len("diff:\n") :]
|
|
211
|
+
if output.startswith("diff --git ") or output.startswith("--- "):
|
|
212
|
+
return output
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_diff_stat(output: str) -> str:
|
|
217
|
+
marker = "\ndiff_stat:\n"
|
|
218
|
+
if marker not in output:
|
|
219
|
+
return ""
|
|
220
|
+
tail = output.split(marker, 1)[1]
|
|
221
|
+
if "\ndiff:\n" in tail:
|
|
222
|
+
tail = tail.split("\ndiff:\n", 1)[0]
|
|
223
|
+
return tail.strip()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _changed_files_from_diff(diff: str) -> list[str]:
|
|
227
|
+
paths: list[str] = []
|
|
228
|
+
for line in diff.splitlines():
|
|
229
|
+
path = None
|
|
230
|
+
if line.startswith("diff --git "):
|
|
231
|
+
match = re.match(r"diff --git a/(.+?) b/(.+)$", line)
|
|
232
|
+
if match:
|
|
233
|
+
path = match.group(2)
|
|
234
|
+
elif line.startswith("+++ "):
|
|
235
|
+
raw = line[4:].split("\t", 1)[0].strip()
|
|
236
|
+
if raw != "/dev/null":
|
|
237
|
+
path = raw[2:] if raw.startswith("b/") else raw
|
|
238
|
+
if path and path not in paths:
|
|
239
|
+
paths.append(path)
|
|
240
|
+
return paths
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _changed_files_from_workspace_state(output: str) -> list[str]:
|
|
244
|
+
paths: list[str] = []
|
|
245
|
+
for line in output.splitlines():
|
|
246
|
+
stripped = line.strip()
|
|
247
|
+
if not stripped or stripped.startswith(("branch:", "changed_files:", "status:")):
|
|
248
|
+
continue
|
|
249
|
+
if stripped[:2] in {"M ", "A ", "D ", "??"}:
|
|
250
|
+
path = stripped[2:].strip()
|
|
251
|
+
if path:
|
|
252
|
+
paths.append(path)
|
|
253
|
+
return paths
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _unique(items: list[str]) -> list[str]:
|
|
257
|
+
values: list[str] = []
|
|
258
|
+
for item in items:
|
|
259
|
+
if item and item not in values:
|
|
260
|
+
values.append(item)
|
|
261
|
+
return values
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Runtime tool registry and handler binding."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
from agent.runtime.context import AgentRuntimeContext
|
|
7
|
+
from agent.runtime.workspace_tools import WORKSPACE_BOUND_TOOLS
|
|
8
|
+
from agent.skills import SkillRegistry
|
|
9
|
+
from agent.subagent import SubagentRunner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONCURRENT_SUBAGENT_ROLES = {"explorer", "architect", "tester", "security"}
|
|
13
|
+
DEFAULT_TOOL_TIMEOUT_SECONDS = 3600
|
|
14
|
+
DEFAULT_TOOL_EXECUTION = {
|
|
15
|
+
"side_effects": "unknown",
|
|
16
|
+
"concurrency": "serial",
|
|
17
|
+
"timeout_seconds": DEFAULT_TOOL_TIMEOUT_SECONDS,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class RuntimeToolRegistry:
|
|
21
|
+
"""Resolve runtime-bound tool handlers and execution metadata."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, runtime: AgentRuntimeContext):
|
|
24
|
+
self.runtime = runtime
|
|
25
|
+
self.todo_handler = runtime.todo_manager.create_todo_handler()
|
|
26
|
+
self.skill_registry = SkillRegistry(runtime.workdir, runtime.skill_dirs)
|
|
27
|
+
self.tool_execution = {
|
|
28
|
+
tool["name"]: tool.get("execution", {})
|
|
29
|
+
for tool in runtime.tools
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def resolve(self, tool_name: str) -> Optional[Callable]:
|
|
33
|
+
"""Return the handler for a tool name."""
|
|
34
|
+
if tool_name == "todo":
|
|
35
|
+
return self.todo_handler
|
|
36
|
+
if tool_name == "list_skills":
|
|
37
|
+
return self.skill_registry.format_skill_list
|
|
38
|
+
if tool_name == "load_skill":
|
|
39
|
+
return lambda names: self.skill_registry.format_loaded_skills(names)
|
|
40
|
+
if tool_name == "subagent":
|
|
41
|
+
return self.create_subagent_runner().run
|
|
42
|
+
handler = self.runtime.tool_handlers.get(tool_name)
|
|
43
|
+
if handler is not None and tool_name in WORKSPACE_BOUND_TOOLS:
|
|
44
|
+
return self._bind_workdir(handler)
|
|
45
|
+
return handler
|
|
46
|
+
|
|
47
|
+
def _bind_workdir(self, handler: Callable) -> Callable:
|
|
48
|
+
"""Inject runtime workdir into workspace-bound tools without exposing it to models."""
|
|
49
|
+
|
|
50
|
+
@wraps(handler)
|
|
51
|
+
def wrapped(*args, **kwargs):
|
|
52
|
+
kwargs.setdefault("workdir", self.runtime.workdir)
|
|
53
|
+
return handler(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
return wrapped
|
|
56
|
+
|
|
57
|
+
def create_subagent_runner(self) -> SubagentRunner:
|
|
58
|
+
"""Create a subagent runner bound to the current runtime."""
|
|
59
|
+
return SubagentRunner(
|
|
60
|
+
provider=self.runtime.provider,
|
|
61
|
+
workdir=self.runtime.workdir,
|
|
62
|
+
parent_system_prompt=self.runtime.system_prompt,
|
|
63
|
+
tool_handlers=self.runtime.tool_handlers,
|
|
64
|
+
tools=self.runtime.tools,
|
|
65
|
+
parent_session_id=self.runtime.session_id,
|
|
66
|
+
skill_dirs=self.runtime.skill_dirs,
|
|
67
|
+
app_root=self.runtime.app_root,
|
|
68
|
+
stream_callback=self.runtime.stream_callback,
|
|
69
|
+
approval_callback=self.runtime.approval_callback,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def execution_for(self, tool_name: str) -> dict:
|
|
73
|
+
"""Return execution policy for a tool."""
|
|
74
|
+
return {**DEFAULT_TOOL_EXECUTION, **self.tool_execution.get(tool_name, {})}
|
|
75
|
+
|
|
76
|
+
def timeout_for(self, tool_name: str) -> int:
|
|
77
|
+
"""Return timeout seconds for a tool."""
|
|
78
|
+
return int(self.execution_for(tool_name)["timeout_seconds"])
|
|
79
|
+
|
|
80
|
+
def is_workspace_write(self, tool_name: str) -> bool:
|
|
81
|
+
"""Return whether a tool mutates the workspace."""
|
|
82
|
+
return self.execution_for(tool_name)["side_effects"] == "workspace_write"
|
|
83
|
+
|
|
84
|
+
def can_run_concurrently(self, tool_call) -> bool:
|
|
85
|
+
"""Return whether a tool call may run in a concurrent batch."""
|
|
86
|
+
if tool_call.name == "subagent":
|
|
87
|
+
return tool_call.args.get("role") in CONCURRENT_SUBAGENT_ROLES
|
|
88
|
+
execution = self.execution_for(tool_call.name)
|
|
89
|
+
if execution["side_effects"] in {"workspace_write", "session_state"}:
|
|
90
|
+
return False
|
|
91
|
+
return execution["concurrency"] == "safe"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tool call scheduling helpers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def execute_tool_calls(
|
|
8
|
+
tool_calls,
|
|
9
|
+
execute_tool_call: Callable[[object], Awaitable[object]],
|
|
10
|
+
can_run_concurrently: Callable[[object], bool],
|
|
11
|
+
) -> list[object]:
|
|
12
|
+
"""Execute tool calls while preserving original result order."""
|
|
13
|
+
results = [None] * len(tool_calls)
|
|
14
|
+
concurrent_batch = []
|
|
15
|
+
|
|
16
|
+
async def flush_concurrent_batch():
|
|
17
|
+
if not concurrent_batch:
|
|
18
|
+
return
|
|
19
|
+
batch = list(concurrent_batch)
|
|
20
|
+
concurrent_batch.clear()
|
|
21
|
+
outputs = await asyncio.gather(
|
|
22
|
+
*(execute_tool_call(tc) for _, tc in batch),
|
|
23
|
+
)
|
|
24
|
+
for (index, _), output in zip(batch, outputs):
|
|
25
|
+
results[index] = output
|
|
26
|
+
|
|
27
|
+
for index, tc in enumerate(tool_calls):
|
|
28
|
+
if can_run_concurrently(tc):
|
|
29
|
+
concurrent_batch.append((index, tc))
|
|
30
|
+
continue
|
|
31
|
+
await flush_concurrent_batch()
|
|
32
|
+
results[index] = await execute_tool_call(tc)
|
|
33
|
+
|
|
34
|
+
await flush_concurrent_batch()
|
|
35
|
+
return results
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Workspace workflow guardrails."""
|
|
2
|
+
|
|
3
|
+
from langchain_core.messages import HumanMessage
|
|
4
|
+
|
|
5
|
+
from agent.runtime.context import AgentRuntimeContext, WorkflowState
|
|
6
|
+
from agent.runtime.tool_events import file_paths_for_tool_call, tool_output_indicates_successful_write
|
|
7
|
+
from agent.runtime.tool_registry import RuntimeToolRegistry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CODE_EXTENSIONS = {
|
|
11
|
+
".c",
|
|
12
|
+
".cc",
|
|
13
|
+
".cpp",
|
|
14
|
+
".cs",
|
|
15
|
+
".css",
|
|
16
|
+
".cxx",
|
|
17
|
+
".go",
|
|
18
|
+
".h",
|
|
19
|
+
".hpp",
|
|
20
|
+
".html",
|
|
21
|
+
".java",
|
|
22
|
+
".js",
|
|
23
|
+
".jsx",
|
|
24
|
+
".kt",
|
|
25
|
+
".kts",
|
|
26
|
+
".m",
|
|
27
|
+
".mm",
|
|
28
|
+
".php",
|
|
29
|
+
".py",
|
|
30
|
+
".rb",
|
|
31
|
+
".rs",
|
|
32
|
+
".scala",
|
|
33
|
+
".scss",
|
|
34
|
+
".sh",
|
|
35
|
+
".swift",
|
|
36
|
+
".ts",
|
|
37
|
+
".tsx",
|
|
38
|
+
".vue",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
VERIFY_CONFIG_EXTENSIONS = {
|
|
42
|
+
".gradle",
|
|
43
|
+
".kts",
|
|
44
|
+
".lock",
|
|
45
|
+
".toml",
|
|
46
|
+
".xml",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
VERIFY_CONFIG_FILENAMES = {
|
|
50
|
+
".eslintrc",
|
|
51
|
+
".eslintrc.cjs",
|
|
52
|
+
".eslintrc.js",
|
|
53
|
+
".eslintrc.json",
|
|
54
|
+
".prettierrc",
|
|
55
|
+
".ruff.toml",
|
|
56
|
+
"Cargo.lock",
|
|
57
|
+
"Cargo.toml",
|
|
58
|
+
"build.gradle",
|
|
59
|
+
"build.gradle.kts",
|
|
60
|
+
"go.mod",
|
|
61
|
+
"go.sum",
|
|
62
|
+
"mypy.ini",
|
|
63
|
+
"package-lock.json",
|
|
64
|
+
"package.json",
|
|
65
|
+
"pnpm-lock.yaml",
|
|
66
|
+
"pom.xml",
|
|
67
|
+
"pyproject.toml",
|
|
68
|
+
"pytest.ini",
|
|
69
|
+
"requirements-dev.txt",
|
|
70
|
+
"requirements.txt",
|
|
71
|
+
"setup.cfg",
|
|
72
|
+
"setup.py",
|
|
73
|
+
"tox.ini",
|
|
74
|
+
"tsconfig.json",
|
|
75
|
+
"yarn.lock",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
VERIFY_CONFIG_SUFFIXES = {
|
|
79
|
+
".csproj",
|
|
80
|
+
".fsproj",
|
|
81
|
+
".props",
|
|
82
|
+
".sln",
|
|
83
|
+
".targets",
|
|
84
|
+
".vbproj",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WorkflowGuard:
|
|
89
|
+
"""Enforce workspace safety and verification workflow rules."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, runtime: AgentRuntimeContext, registry: RuntimeToolRegistry):
|
|
92
|
+
self.runtime = runtime
|
|
93
|
+
self.registry = registry
|
|
94
|
+
self.state: WorkflowState = runtime.workflow_state
|
|
95
|
+
|
|
96
|
+
def has_preflight(self) -> bool:
|
|
97
|
+
"""Return whether workspace state and diff have been checked."""
|
|
98
|
+
return self.state.workspace_state_checked and self.state.git_diff_checked
|
|
99
|
+
|
|
100
|
+
def path_exists(self, path: str) -> bool:
|
|
101
|
+
"""Return whether a workspace-relative path exists."""
|
|
102
|
+
if not path:
|
|
103
|
+
return False
|
|
104
|
+
return (self.runtime.workdir / path).resolve().exists()
|
|
105
|
+
|
|
106
|
+
def should_require_apply_patch(self, tc) -> bool:
|
|
107
|
+
"""Return whether a tool call should be redirected to apply_patch."""
|
|
108
|
+
if tc.name == "edit_file":
|
|
109
|
+
return True
|
|
110
|
+
if tc.name == "write_file":
|
|
111
|
+
return self.path_exists(tc.args.get("path", ""))
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def apply_patch_required_message(self, tc) -> str:
|
|
115
|
+
"""Return a message explaining why apply_patch is required."""
|
|
116
|
+
path = tc.args.get("path", "")
|
|
117
|
+
if tc.name == "write_file":
|
|
118
|
+
return (
|
|
119
|
+
f"Code workflow guard blocked write_file for existing file: {path}\n\n"
|
|
120
|
+
"Use apply_patch with path + old_text + new_text, or a unified diff, "
|
|
121
|
+
"for existing file edits. "
|
|
122
|
+
"write_file is only allowed for brand-new files or generated artifacts."
|
|
123
|
+
)
|
|
124
|
+
return (
|
|
125
|
+
f"Code workflow guard blocked edit_file for: {path}\n\n"
|
|
126
|
+
"Use apply_patch with path + old_text + new_text, or a unified diff, "
|
|
127
|
+
"for code edits so the change is reviewable "
|
|
128
|
+
"and the diff can be shown to the user."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def run_preflight(self) -> str:
|
|
132
|
+
"""Collect workspace state and diff before allowing a write tool."""
|
|
133
|
+
workspace_output = await self.runtime.run_tool(
|
|
134
|
+
self.registry.resolve("workspace_state"),
|
|
135
|
+
"workspace_state",
|
|
136
|
+
max_retries=0,
|
|
137
|
+
timeout_seconds=self.registry.timeout_for("workspace_state"),
|
|
138
|
+
)
|
|
139
|
+
diff_output = await self.runtime.run_tool(
|
|
140
|
+
self.registry.resolve("git_diff"),
|
|
141
|
+
"git_diff",
|
|
142
|
+
max_retries=0,
|
|
143
|
+
timeout_seconds=self.registry.timeout_for("git_diff"),
|
|
144
|
+
)
|
|
145
|
+
self.state.workspace_state_checked = True
|
|
146
|
+
self.state.git_diff_checked = True
|
|
147
|
+
return (
|
|
148
|
+
"Code workflow guard blocked this write because workspace preflight "
|
|
149
|
+
"had not been reviewed yet.\n\n"
|
|
150
|
+
"workspace_state:\n"
|
|
151
|
+
f"{workspace_output}\n\n"
|
|
152
|
+
"git_diff:\n"
|
|
153
|
+
f"{diff_output}\n\n"
|
|
154
|
+
"Review the existing changes, then retry the write with the smallest safe patch."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def update_after_tool(self, tool_call, output: str) -> bool:
|
|
158
|
+
"""Update workflow state after a tool and return whether a diff event is needed."""
|
|
159
|
+
tool_name = tool_call.name
|
|
160
|
+
if tool_name == "workspace_state":
|
|
161
|
+
self.state.workspace_state_checked = True
|
|
162
|
+
if tool_name == "git_diff":
|
|
163
|
+
self.state.git_diff_checked = True
|
|
164
|
+
if tool_name == "verify":
|
|
165
|
+
self.state.needs_verify = False
|
|
166
|
+
if self.registry.is_workspace_write(tool_name) and tool_output_indicates_successful_write(output):
|
|
167
|
+
self.state.needs_verify = paths_need_code_verification(file_paths_for_tool_call(tool_call))
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def after_batch_messages(self, tool_calls_data: list) -> list[HumanMessage]:
|
|
172
|
+
"""Return extra HumanMessages to append after a tools batch."""
|
|
173
|
+
additional_messages = []
|
|
174
|
+
todo_manager = self.runtime.todo_manager
|
|
175
|
+
if todo_manager.needs_reminder():
|
|
176
|
+
additional_messages.append(
|
|
177
|
+
HumanMessage(
|
|
178
|
+
content=todo_manager.consume_reminder_message(),
|
|
179
|
+
additional_kwargs={
|
|
180
|
+
"context_ephemeral": True,
|
|
181
|
+
"ephemeral_kind": "task_reminder",
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
if self.state.needs_verify and not any(tc.name == "verify" for tc in tool_calls_data):
|
|
186
|
+
additional_messages.append(
|
|
187
|
+
HumanMessage(
|
|
188
|
+
content=(
|
|
189
|
+
"Code changes were made. Run verify with the narrowest useful "
|
|
190
|
+
"target before providing the final answer."
|
|
191
|
+
),
|
|
192
|
+
additional_kwargs={
|
|
193
|
+
"context_ephemeral": True,
|
|
194
|
+
"ephemeral_kind": "verify_reminder",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return additional_messages
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def paths_need_code_verification(paths: list[str]) -> bool:
|
|
202
|
+
"""Return whether changed paths should trigger code verification."""
|
|
203
|
+
if not paths:
|
|
204
|
+
return True
|
|
205
|
+
return any(path_needs_code_verification(path) for path in paths)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def path_needs_code_verification(path: str) -> bool:
|
|
209
|
+
"""Return whether a single path is code or known build/test configuration."""
|
|
210
|
+
normalized = path.replace("\\", "/").rstrip("/")
|
|
211
|
+
name = normalized.rsplit("/", 1)[-1]
|
|
212
|
+
if name in VERIFY_CONFIG_FILENAMES:
|
|
213
|
+
return True
|
|
214
|
+
if any(name.endswith(suffix) for suffix in VERIFY_CONFIG_SUFFIXES):
|
|
215
|
+
return True
|
|
216
|
+
extension = "." + name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
217
|
+
return extension in CODE_EXTENSIONS or extension in VERIFY_CONFIG_EXTENSIONS
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Workspace-bound tool names used by runtime and subagents."""
|
|
2
|
+
|
|
3
|
+
WORKSPACE_BOUND_TOOLS = {
|
|
4
|
+
"read_file",
|
|
5
|
+
"read_many_files",
|
|
6
|
+
"write_file",
|
|
7
|
+
"edit_file",
|
|
8
|
+
"apply_patch",
|
|
9
|
+
"grep",
|
|
10
|
+
"list_files",
|
|
11
|
+
"lsp_definition",
|
|
12
|
+
"lsp_diagnostics",
|
|
13
|
+
"lsp_document_symbols",
|
|
14
|
+
"lsp_hover",
|
|
15
|
+
"lsp_references",
|
|
16
|
+
"lsp_workspace_symbols",
|
|
17
|
+
"git_show",
|
|
18
|
+
"git_diff",
|
|
19
|
+
"workspace_state",
|
|
20
|
+
"verify",
|
|
21
|
+
"bash",
|
|
22
|
+
}
|