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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. 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,5 @@
1
+ """Runtime import compatibility for workspace helpers."""
2
+
3
+ from tools.workspace import Workspace, resolve_workspace
4
+
5
+ __all__ = ["Workspace", "resolve_workspace"]
@@ -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
+ }