snodo-engine 0.7.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.
- snodo/coders/__init__.py +108 -0
- snodo/coders/agy_adapter.py +38 -0
- snodo/coders/anthropic_adapter.py +22 -0
- snodo/coders/base.py +365 -0
- snodo/coders/gemini_adapter.py +28 -0
- snodo/coders/litellm.py +1439 -0
- snodo/coders/mock.py +203 -0
- snodo/coders/openai_adapter.py +16 -0
- snodo/coders/opencode_adapter.py +327 -0
- snodo/coders/opencode_cli_adapter.py +34 -0
- snodo/coders/opencode_container.py +267 -0
- snodo/coders/subprocess_adapter.py +219 -0
- snodo/engine/__init__.py +0 -0
- snodo/engine/closure.py +295 -0
- snodo/engine/constraints.py +120 -0
- snodo/engine/loop.py +1002 -0
- snodo/engine/nodes/__init__.py +1 -0
- snodo/engine/nodes/context.py +137 -0
- snodo/engine/nodes/executor.py +168 -0
- snodo/engine/nodes/governance.py +289 -0
- snodo/engine/nodes/state.py +160 -0
- snodo/engine/nodes/validation.py +563 -0
- snodo/engine/nodes/writeback.py +597 -0
- snodo/engine/policy.py +411 -0
- snodo/engine/progress.py +71 -0
- snodo/engine/state.py +105 -0
- snodo/engine/validators.py +90 -0
- snodo/validators/__init__.py +6 -0
- snodo/validators/acceptance.py +164 -0
- snodo/validators/context.py +65 -0
- snodo/validators/llm_validator.py +1044 -0
- snodo/validators/protocol_adherence.py +331 -0
- snodo/validators/quality.py +376 -0
- snodo/validators/registry.py +52 -0
- snodo/validators/runner.py +429 -0
- snodo_engine-0.7.2.dist-info/METADATA +19 -0
- snodo_engine-0.7.2.dist-info/RECORD +39 -0
- snodo_engine-0.7.2.dist-info/WHEEL +5 -0
- snodo_engine-0.7.2.dist-info/top_level.txt +1 -0
snodo/coders/litellm.py
ADDED
|
@@ -0,0 +1,1439 @@
|
|
|
1
|
+
"""LiteLLM coder adapter.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/coders/litellm.py
|
|
4
|
+
|
|
5
|
+
Implements CoderAdapter using LangChain + liteLLM for model abstraction.
|
|
6
|
+
|
|
7
|
+
Bounded tool-use loop (added):
|
|
8
|
+
- When workspace_mcp is available, _call_llm runs a bounded read-only
|
|
9
|
+
tool-use loop over completion_fn(tools=[...]) so the coder can read
|
|
10
|
+
current file contents before generating a CodeArtifact.
|
|
11
|
+
- Read-only tools: read_file, read_file_lines, list_files.
|
|
12
|
+
- NO write tool, NO shell. The coder still returns a CodeArtifact;
|
|
13
|
+
the executor owns writes.
|
|
14
|
+
- Bounded to _MAX_TOOL_TURNS turns. When no read is needed, the model
|
|
15
|
+
returns the CodeArtifact on the first turn (behaviour-equivalent to
|
|
16
|
+
the old single-completion path).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from snodo.core.interfaces import TaskSpec, CodeArtifact, FileArtifact, MCPServer
|
|
27
|
+
from snodo.coders.base import CoderAdapter, LLMCallError, ParseError, TurnBudgetExhausted
|
|
28
|
+
from snodo.engine.progress import format_elapsed, format_tool_call_summary
|
|
29
|
+
from snodo.infrastructure.config import DEFAULT_MODEL
|
|
30
|
+
from snodo.infrastructure.usage_tracker import UsageTracker
|
|
31
|
+
|
|
32
|
+
import litellm as _litellm
|
|
33
|
+
_litellm.drop_params = True
|
|
34
|
+
_litellm.suppress_debug_info = True
|
|
35
|
+
|
|
36
|
+
# Retry transient errors (5xx, 429, connection) with exponential backoff
|
|
37
|
+
try:
|
|
38
|
+
from snodo.infrastructure.config import load_llm_config
|
|
39
|
+
_litellm.num_retries = load_llm_config().num_retries
|
|
40
|
+
except Exception:
|
|
41
|
+
_litellm.num_retries = 3
|
|
42
|
+
|
|
43
|
+
if not getattr(_litellm, "success_callback", None):
|
|
44
|
+
_litellm.success_callback = []
|
|
45
|
+
_litellm.success_callback.append(UsageTracker())
|
|
46
|
+
|
|
47
|
+
import logging as _logging # noqa: E402 — must run after litellm is configured above
|
|
48
|
+
|
|
49
|
+
# CF models absent from models.dev catalog — price via register_model. The
|
|
50
|
+
# register_model call emits a WARNING per model ("not in built-in cost map")
|
|
51
|
+
# because the entries lack cache cost fields. These fire on every import —
|
|
52
|
+
# including `snodo --version` — and publish the model catalog into the
|
|
53
|
+
# terminal. Suppress the LiteLLM logger for the duration of the call only,
|
|
54
|
+
# then restore the previous level: a real cost or routing warning during a
|
|
55
|
+
# run must still surface (Fixes #135).
|
|
56
|
+
_litellm_logger = _logging.getLogger("LiteLLM")
|
|
57
|
+
_prev_litellm_level = _litellm_logger.level
|
|
58
|
+
_litellm_logger.setLevel(_logging.CRITICAL)
|
|
59
|
+
try:
|
|
60
|
+
_litellm.register_model({
|
|
61
|
+
"openai/@cf/google/gemma-4-26b-a4b-it": {
|
|
62
|
+
"input_cost_per_token": 0.10 / 1_000_000,
|
|
63
|
+
"output_cost_per_token": 0.30 / 1_000_000,
|
|
64
|
+
},
|
|
65
|
+
"openai/@cf/nvidia/nemotron-3-120b-a12b": {
|
|
66
|
+
"input_cost_per_token": 0.50 / 1_000_000,
|
|
67
|
+
"output_cost_per_token": 1.50 / 1_000_000,
|
|
68
|
+
},
|
|
69
|
+
"openai/@cf/moonshotai/kimi-k2.6": {
|
|
70
|
+
"input_cost_per_token": 0.95 / 1_000_000,
|
|
71
|
+
"output_cost_per_token": 4.00 / 1_000_000,
|
|
72
|
+
},
|
|
73
|
+
"openai/@cf/moonshotai/kimi-k2.7-code": {
|
|
74
|
+
"input_cost_per_token": 0.95 / 1_000_000,
|
|
75
|
+
"output_cost_per_token": 4.00 / 1_000_000,
|
|
76
|
+
},
|
|
77
|
+
"openai/@cf/mistralai/mistral-small-3.1-24b-instruct": {
|
|
78
|
+
"input_cost_per_token": 0.35 / 1_000_000,
|
|
79
|
+
"output_cost_per_token": 0.55 / 1_000_000,
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
finally:
|
|
83
|
+
_litellm_logger.setLevel(_prev_litellm_level)
|
|
84
|
+
|
|
85
|
+
_logger = logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Maximum tool-use turns before forcing a CodeArtifact parse.
|
|
89
|
+
_DEFAULT_MAX_TOOL_TURNS = 20
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_gemini3_plus(model: str) -> bool:
|
|
93
|
+
"""Check if model is Gemini 3.0 or higher."""
|
|
94
|
+
return "gemini-3" in model.lower() or "gemini-4" in model.lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_test_governing_file(path: str) -> bool:
|
|
98
|
+
"""Check if a file path governs test behavior (ADR 040 Point 5)."""
|
|
99
|
+
p = str(path).replace("\\", "/").strip().lower()
|
|
100
|
+
parts = p.split("/")
|
|
101
|
+
filename = parts[-1]
|
|
102
|
+
|
|
103
|
+
if any(part in {"tests", "test", "spec", "specs"} for part in parts[:-1]):
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
filename.startswith("test_")
|
|
108
|
+
or filename.endswith("_test.py")
|
|
109
|
+
or filename.endswith(".test.js")
|
|
110
|
+
or filename.endswith(".test.ts")
|
|
111
|
+
or filename.endswith(".spec.js")
|
|
112
|
+
or filename.endswith(".spec.ts")
|
|
113
|
+
):
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
governing_filenames = {
|
|
117
|
+
"conftest.py",
|
|
118
|
+
"pytest.ini",
|
|
119
|
+
"tox.ini",
|
|
120
|
+
"pyproject.toml",
|
|
121
|
+
".coveragerc",
|
|
122
|
+
"setup.cfg",
|
|
123
|
+
"cargo.toml",
|
|
124
|
+
"package.json",
|
|
125
|
+
"jest.config.js",
|
|
126
|
+
"jest.config.ts",
|
|
127
|
+
"vitest.config.ts",
|
|
128
|
+
}
|
|
129
|
+
return filename in governing_filenames
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LiteLLMAdapter(CoderAdapter):
|
|
133
|
+
"""Base coder adapter using liteLLM as transport.
|
|
134
|
+
|
|
135
|
+
Provider-agnostic tool-use loop. Subclasses override only
|
|
136
|
+
TRUNCATION_REASONS and (optionally) _call_llm_with_tools for
|
|
137
|
+
provider-specific message shaping.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
TRUNCATION_REASONS: set[str] = {"length", "max_tokens", "MAX_TOKENS"}
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
model: str = DEFAULT_MODEL,
|
|
145
|
+
mcp_servers: Optional[List[MCPServer]] = None,
|
|
146
|
+
temperature: float = 0.7,
|
|
147
|
+
max_tokens: int = 16000,
|
|
148
|
+
max_tool_turns: Optional[int] = None,
|
|
149
|
+
workspace_mcp: Optional[Any] = None,
|
|
150
|
+
progress_callback: Optional[Any] = None,
|
|
151
|
+
):
|
|
152
|
+
self.model = model
|
|
153
|
+
self.mcp_servers = mcp_servers or []
|
|
154
|
+
self.temperature = temperature
|
|
155
|
+
self.max_tokens = max_tokens
|
|
156
|
+
self.max_tool_turns = max_tool_turns if max_tool_turns is not None else _DEFAULT_MAX_TOOL_TURNS
|
|
157
|
+
self.workspace_mcp = workspace_mcp
|
|
158
|
+
self.progress_callback = progress_callback
|
|
159
|
+
self.observes_tests = True
|
|
160
|
+
|
|
161
|
+
self._job_id: str = ""
|
|
162
|
+
self._task_id: str = ""
|
|
163
|
+
#: Per-attempt read memory; None until a tool-loop run populates it.
|
|
164
|
+
#: Exposed as paths only (see ``last_read_paths``) so a later recovery
|
|
165
|
+
#: attempt can reuse the map of what was inspected, never its contents.
|
|
166
|
+
self._read_tracker: Optional["ReadMemoryTracker"] = None
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
from litellm import completion
|
|
170
|
+
self._completion_fn = completion
|
|
171
|
+
except ImportError:
|
|
172
|
+
self._completion_fn = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def last_read_paths(self) -> List[str]:
|
|
176
|
+
"""File paths opened by the most recent attempt (paths only, no contents)."""
|
|
177
|
+
return list(self._read_tracker.file_ranges) if self._read_tracker else []
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def last_listed_dirs(self) -> List[str]:
|
|
181
|
+
"""Directory paths listed by the most recent attempt."""
|
|
182
|
+
return list(self._read_tracker.dir_listings) if self._read_tracker else []
|
|
183
|
+
|
|
184
|
+
def _resolve_api_base(self) -> Optional[str]:
|
|
185
|
+
"""Return api_base for the current model, if provider has base_url set."""
|
|
186
|
+
from snodo.config import ConfigManager
|
|
187
|
+
return ConfigManager.resolve_api_base(self.model)
|
|
188
|
+
|
|
189
|
+
def _resolve_extra_headers(self) -> Optional[dict]:
|
|
190
|
+
"""Return extra_headers for the model's provider."""
|
|
191
|
+
from snodo.config import ConfigManager
|
|
192
|
+
return ConfigManager.resolve_extra_headers(self.model, task_id=self._task_id)
|
|
193
|
+
|
|
194
|
+
def implement(self, spec: TaskSpec) -> CodeArtifact:
|
|
195
|
+
self._read_tracker = None
|
|
196
|
+
prompt = self._build_prompt(spec)
|
|
197
|
+
response = self._call_llm(prompt)
|
|
198
|
+
return self._parse_response(response)
|
|
199
|
+
|
|
200
|
+
def _build_prompt(self, spec: TaskSpec) -> str:
|
|
201
|
+
language = spec.project_context.get("language", "unknown")
|
|
202
|
+
lang_hint = f" ({language} project)" if language != "unknown" else ""
|
|
203
|
+
|
|
204
|
+
prompt_parts = [
|
|
205
|
+
f"You are an expert software engineer{lang_hint}. "
|
|
206
|
+
"Generate code based on this specification:\n",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
# Project context section
|
|
210
|
+
structure = spec.project_context.get("structure", "")
|
|
211
|
+
config_files = spec.project_context.get("config_files", {})
|
|
212
|
+
if structure or config_files:
|
|
213
|
+
prompt_parts.append("\n## Project Context\n")
|
|
214
|
+
if structure:
|
|
215
|
+
prompt_parts.append(f"Directory structure:\n```\n{structure}\n```\n")
|
|
216
|
+
for cfg_name, cfg_content in config_files.items():
|
|
217
|
+
prompt_parts.append(f"{cfg_name}:\n```\n{cfg_content}\n```\n")
|
|
218
|
+
|
|
219
|
+
# Task section
|
|
220
|
+
prompt_parts.append(f"\n## Task\nDescription: {spec.description}\n")
|
|
221
|
+
|
|
222
|
+
if spec.constraints:
|
|
223
|
+
prompt_parts.append("\nConstraints:")
|
|
224
|
+
for constraint in spec.constraints:
|
|
225
|
+
prompt_parts.append(f"- {constraint}")
|
|
226
|
+
prompt_parts.append("\n")
|
|
227
|
+
|
|
228
|
+
# Tool hint (when workspace available)
|
|
229
|
+
if self.workspace_mcp is not None:
|
|
230
|
+
prompt_parts.append(
|
|
231
|
+
"\n## Available Tools\n"
|
|
232
|
+
"Tool calls and turns are expensive! Prefer reading multiple files in a single turn using "
|
|
233
|
+
"`read_files(paths=[...])` rather than making individual `read_file` calls. You may also use "
|
|
234
|
+
"`read_file(path)` for a single file, `read_file_lines(path, start, end)` for line ranges, and "
|
|
235
|
+
"`list_files(directory)` to explore the project.\n"
|
|
236
|
+
"You can search inside file contents using `search_string(query, directory)` — `query` is the text to FIND, "
|
|
237
|
+
"and the folder to look in goes in `directory` — and locate symbol definitions using "
|
|
238
|
+
"`search_symbol(name, directory)` — `name` is an identifier like \"AuthManager\", never a path.\n"
|
|
239
|
+
"You can run the project's declared test runner to observe test output before submitting using `run_tests(test_path, command_type)`.\n"
|
|
240
|
+
"Read existing files you need to modify so you can make faithful edits.\n"
|
|
241
|
+
"\n"
|
|
242
|
+
"When you are ready to deliver your changes, call the `submit_files(files)` tool. "
|
|
243
|
+
"You may call `submit_files` multiple times across turns to stage or update files. "
|
|
244
|
+
"File operations accumulate by path across calls and are delivered atomically when complete. "
|
|
245
|
+
"Do NOT emit file content as prose or as a JSON text blob.\n"
|
|
246
|
+
"To remove obsolete or orphaned files created in earlier attempts, include a file item with action: \"delete\" (content is optional for deletes).\n"
|
|
247
|
+
"\n"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
prompt_parts.append("""
|
|
251
|
+
## Output Format
|
|
252
|
+
Your response MUST be a JSON array of file operations. Each element has:
|
|
253
|
+
- "path": file path relative to the project root
|
|
254
|
+
- "content": the full file content (required for "write", optional for "delete")
|
|
255
|
+
- "action": "write" (default) or "delete" (use to remove obsolete or orphaned files)
|
|
256
|
+
|
|
257
|
+
Return ONLY the JSON array, no other text.
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
[
|
|
261
|
+
{"path": "src/module.py", "content": "def my_function():\\n pass\\n", "action": "write"},
|
|
262
|
+
{"path": "tests/test_module.py", "content": "def test_my_function():\\n assert my_function() is not None\\n", "action": "write"},
|
|
263
|
+
{"path": "src/old_orphan.py", "action": "delete"}
|
|
264
|
+
]
|
|
265
|
+
```
|
|
266
|
+
""")
|
|
267
|
+
|
|
268
|
+
if spec.memory_summary:
|
|
269
|
+
prompt_parts.append(f"\n## Session History\n{spec.memory_summary}\n")
|
|
270
|
+
|
|
271
|
+
prompt_parts.append("Now generate the implementation:\n")
|
|
272
|
+
|
|
273
|
+
return "".join(prompt_parts)
|
|
274
|
+
|
|
275
|
+
def _call_llm(self, prompt: str) -> str:
|
|
276
|
+
if self._completion_fn is None:
|
|
277
|
+
raise LLMCallError(
|
|
278
|
+
"litellm not available. Install with: pip install litellm"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# When workspace_mcp is available, use bounded tool-use loop
|
|
282
|
+
if self.workspace_mcp is not None:
|
|
283
|
+
return self._call_llm_with_tools(prompt)
|
|
284
|
+
|
|
285
|
+
# Fallback: single raw completion (backward-compatible)
|
|
286
|
+
try:
|
|
287
|
+
from snodo.config import ConfigManager
|
|
288
|
+
kwargs = {
|
|
289
|
+
"model": ConfigManager.resolve_litellm_model(self.model),
|
|
290
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
291
|
+
"max_tokens": self.max_tokens,
|
|
292
|
+
"metadata": {
|
|
293
|
+
"job_id": self._job_id or "unknown",
|
|
294
|
+
"task_id": self._task_id or "unknown",
|
|
295
|
+
"role": "coder",
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
api_base = self._resolve_api_base()
|
|
299
|
+
if api_base:
|
|
300
|
+
kwargs["api_base"] = api_base
|
|
301
|
+
extra_headers = self._resolve_extra_headers()
|
|
302
|
+
if extra_headers:
|
|
303
|
+
kwargs["extra_headers"] = extra_headers
|
|
304
|
+
if not _is_gemini3_plus(self.model):
|
|
305
|
+
kwargs["temperature"] = self.temperature
|
|
306
|
+
response = self._completion_fn(**kwargs)
|
|
307
|
+
self._check_truncation(response)
|
|
308
|
+
return response.choices[0].message.content
|
|
309
|
+
except (LLMCallError, ParseError):
|
|
310
|
+
raise
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise LLMCallError(f"LLM call failed: {e}") from e
|
|
313
|
+
|
|
314
|
+
def _finalize_accumulated_deliveries(
|
|
315
|
+
self,
|
|
316
|
+
accumulated_files: Dict[str, Dict[str, Any]],
|
|
317
|
+
test_governing_mutations: Dict[str, Dict[str, str]],
|
|
318
|
+
) -> str:
|
|
319
|
+
"""Finalize accumulated deliveries, record audit events, and attach metadata."""
|
|
320
|
+
metadata: Dict[str, Any] = {}
|
|
321
|
+
if test_governing_mutations:
|
|
322
|
+
mutations_list = list(test_governing_mutations.values())
|
|
323
|
+
metadata["test_governing_mutations"] = mutations_list
|
|
324
|
+
metadata["test_mutation_detected"] = True
|
|
325
|
+
self._emit_audit_event(
|
|
326
|
+
"test_modified",
|
|
327
|
+
{
|
|
328
|
+
"mutations": mutations_list,
|
|
329
|
+
"job_id": self._job_id,
|
|
330
|
+
"task_id": self._task_id,
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
self._last_artifact_metadata = metadata
|
|
334
|
+
return json.dumps(list(accumulated_files.values()))
|
|
335
|
+
|
|
336
|
+
def _call_llm_with_tools(self, prompt: str) -> str:
|
|
337
|
+
"""Bounded tool-use loop with submit_files, search tools, and test runner observation."""
|
|
338
|
+
workspace = self.workspace_mcp
|
|
339
|
+
tools = self._build_tool_definitions()
|
|
340
|
+
tools.append(self._SUBMIT_FILES_DEF)
|
|
341
|
+
|
|
342
|
+
messages: List[Dict[str, Any]] = [
|
|
343
|
+
{"role": "user", "content": prompt},
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
retried_free_text = False
|
|
347
|
+
finish_reason = None
|
|
348
|
+
start_time = time.monotonic()
|
|
349
|
+
read_tracker = ReadMemoryTracker(getattr(workspace, "project_root", None))
|
|
350
|
+
accumulated_files: Dict[str, Dict[str, Any]] = {}
|
|
351
|
+
test_governing_mutations: Dict[str, Dict[str, str]] = {}
|
|
352
|
+
self._read_tracker = read_tracker
|
|
353
|
+
|
|
354
|
+
for turn in range(self.max_tool_turns):
|
|
355
|
+
turn_start = time.monotonic()
|
|
356
|
+
test_runs_this_turn = 0
|
|
357
|
+
try:
|
|
358
|
+
from snodo.config import ConfigManager
|
|
359
|
+
kwargs = {
|
|
360
|
+
"model": ConfigManager.resolve_litellm_model(self.model),
|
|
361
|
+
"messages": messages,
|
|
362
|
+
"tools": tools,
|
|
363
|
+
"parallel_tool_calls": True,
|
|
364
|
+
"max_tokens": self.max_tokens,
|
|
365
|
+
"metadata": {
|
|
366
|
+
"job_id": self._job_id or "unknown",
|
|
367
|
+
"task_id": self._task_id or "unknown",
|
|
368
|
+
"role": "coder",
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
api_base = self._resolve_api_base()
|
|
372
|
+
if api_base:
|
|
373
|
+
kwargs["api_base"] = api_base
|
|
374
|
+
extra_headers = self._resolve_extra_headers()
|
|
375
|
+
if extra_headers:
|
|
376
|
+
kwargs["extra_headers"] = extra_headers
|
|
377
|
+
if not _is_gemini3_plus(self.model):
|
|
378
|
+
kwargs["temperature"] = self.temperature
|
|
379
|
+
response = self._completion_fn(**kwargs)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
self._emit_turn_telemetry(
|
|
382
|
+
turn_index=turn + 1,
|
|
383
|
+
tool="error",
|
|
384
|
+
target_path="",
|
|
385
|
+
read_hit=False,
|
|
386
|
+
tokens_in=0,
|
|
387
|
+
tokens_out=0,
|
|
388
|
+
elapsed_ms=(time.monotonic() - turn_start) * 1000,
|
|
389
|
+
)
|
|
390
|
+
staged_info = f" ({len(accumulated_files)} file(s) staged)" if accumulated_files else ""
|
|
391
|
+
raise LLMCallError(
|
|
392
|
+
f"LLM tool-loop error on turn {turn + 1}{staged_info}: {e}"
|
|
393
|
+
) from e
|
|
394
|
+
|
|
395
|
+
self._check_truncation(response)
|
|
396
|
+
|
|
397
|
+
msg = response.choices[0].message
|
|
398
|
+
tool_calls = getattr(msg, "tool_calls", [])
|
|
399
|
+
finish_reason = getattr(response.choices[0], "finish_reason", None)
|
|
400
|
+
|
|
401
|
+
if self.progress_callback:
|
|
402
|
+
elapsed_str = format_elapsed(time.monotonic() - start_time)
|
|
403
|
+
tools_str = format_tool_call_summary(tool_calls)
|
|
404
|
+
self.progress_callback(f" [{elapsed_str}] Turn {turn + 1}: {tools_str}")
|
|
405
|
+
|
|
406
|
+
if tool_calls:
|
|
407
|
+
messages.append({
|
|
408
|
+
"role": "assistant",
|
|
409
|
+
"content": msg.content,
|
|
410
|
+
"tool_calls": [
|
|
411
|
+
{
|
|
412
|
+
"id": tc.id,
|
|
413
|
+
"type": "function",
|
|
414
|
+
"function": {
|
|
415
|
+
"name": tc.function.name,
|
|
416
|
+
"arguments": tc.function.arguments,
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
for tc in tool_calls
|
|
420
|
+
],
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
for tc in tool_calls:
|
|
424
|
+
tool_name = tc.function.name
|
|
425
|
+
if tool_name == "submit_files":
|
|
426
|
+
files_list = self._extract_submit_files(tc)
|
|
427
|
+
if files_list is not None and files_list:
|
|
428
|
+
for f in files_list:
|
|
429
|
+
if isinstance(f, dict) and "path" in f:
|
|
430
|
+
path = f["path"]
|
|
431
|
+
action = f.get("action", "write")
|
|
432
|
+
accumulated_files[path] = f
|
|
433
|
+
if _is_test_governing_file(path):
|
|
434
|
+
test_governing_mutations[path] = {
|
|
435
|
+
"path": path,
|
|
436
|
+
"kind": action,
|
|
437
|
+
}
|
|
438
|
+
self._emit_turn_telemetry(
|
|
439
|
+
turn_index=turn + 1,
|
|
440
|
+
tool="submit_files",
|
|
441
|
+
target_path="",
|
|
442
|
+
read_hit=False,
|
|
443
|
+
tokens_in=_usage_tokens(response, "prompt"),
|
|
444
|
+
tokens_out=_usage_tokens(response, "completion"),
|
|
445
|
+
elapsed_ms=(time.monotonic() - turn_start) * 1000,
|
|
446
|
+
submit_bytes=len(json.dumps(files_list).encode("utf-8")),
|
|
447
|
+
)
|
|
448
|
+
staged_count = len(files_list)
|
|
449
|
+
total_count = len(accumulated_files)
|
|
450
|
+
result = (
|
|
451
|
+
f"Staged {staged_count} file operation(s). Total accumulated across turns: {total_count} file(s). "
|
|
452
|
+
f"You may call submit_files again to stage additional or updated files, or finish when complete."
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
result = self._submit_files_feedback(tc)
|
|
456
|
+
elif tool_name == "run_tests":
|
|
457
|
+
if test_runs_this_turn >= 3:
|
|
458
|
+
result = "Test execution limit reached for this turn (max 3 runs per turn). Proceed with implementation or submit_files."
|
|
459
|
+
else:
|
|
460
|
+
test_runs_this_turn += 1
|
|
461
|
+
try:
|
|
462
|
+
args = json.loads(tc.function.arguments)
|
|
463
|
+
except (json.JSONDecodeError, TypeError):
|
|
464
|
+
args = {}
|
|
465
|
+
test_path = args.get("test_path", "")
|
|
466
|
+
command_type = args.get("command_type", "pytest")
|
|
467
|
+
result = self._execute_coder_run_tests(test_path, command_type, workspace, turn + 1)
|
|
468
|
+
self._emit_turn_telemetry(
|
|
469
|
+
turn_index=turn + 1,
|
|
470
|
+
tool="run_tests",
|
|
471
|
+
target_path=_normalize_path_arg(args if 'args' in locals() else {}),
|
|
472
|
+
read_hit=False,
|
|
473
|
+
tokens_in=_usage_tokens(response, "prompt"),
|
|
474
|
+
tokens_out=_usage_tokens(response, "completion"),
|
|
475
|
+
elapsed_ms=(time.monotonic() - turn_start) * 1000,
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
try:
|
|
479
|
+
args = json.loads(tc.function.arguments)
|
|
480
|
+
except (json.JSONDecodeError, TypeError):
|
|
481
|
+
args = {}
|
|
482
|
+
prev_turn = read_tracker.check_read(tool_name, args)
|
|
483
|
+
if prev_turn is not None:
|
|
484
|
+
result = format_repeat_read_response(tool_name, args, prev_turn)
|
|
485
|
+
else:
|
|
486
|
+
result = self._execute_tool(tool_name, args, workspace)
|
|
487
|
+
read_tracker.record_read(tool_name, args, turn + 1)
|
|
488
|
+
self._emit_turn_telemetry(
|
|
489
|
+
turn_index=turn + 1,
|
|
490
|
+
tool=tool_name,
|
|
491
|
+
target_path=_normalize_path_arg(args),
|
|
492
|
+
read_hit=prev_turn is not None,
|
|
493
|
+
tokens_in=_usage_tokens(response, "prompt"),
|
|
494
|
+
tokens_out=_usage_tokens(response, "completion"),
|
|
495
|
+
elapsed_ms=(time.monotonic() - turn_start) * 1000,
|
|
496
|
+
)
|
|
497
|
+
messages.append({
|
|
498
|
+
"role": "tool",
|
|
499
|
+
"tool_call_id": tc.id,
|
|
500
|
+
"content": str(result),
|
|
501
|
+
})
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
# No tool calls on this turn — deliver accumulated files if any
|
|
505
|
+
if accumulated_files:
|
|
506
|
+
return self._finalize_accumulated_deliveries(accumulated_files, test_governing_mutations)
|
|
507
|
+
|
|
508
|
+
# No tool calls — free-text, try corrective retry once
|
|
509
|
+
if msg.content is not None and not retried_free_text:
|
|
510
|
+
retried_free_text = True
|
|
511
|
+
messages.append({
|
|
512
|
+
"role": "assistant",
|
|
513
|
+
"content": msg.content,
|
|
514
|
+
})
|
|
515
|
+
messages.append({
|
|
516
|
+
"role": "user",
|
|
517
|
+
"content": (
|
|
518
|
+
"Deliver your changes by calling "
|
|
519
|
+
"submit_files(files=[...]). Do not "
|
|
520
|
+
"emit them as text."
|
|
521
|
+
),
|
|
522
|
+
})
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# Fallback: try to parse free-text as file operations
|
|
526
|
+
if msg.content is not None:
|
|
527
|
+
return self._try_parse_or_fail(
|
|
528
|
+
msg.content, turn, finish_reason,
|
|
529
|
+
)
|
|
530
|
+
break
|
|
531
|
+
else:
|
|
532
|
+
# The loop completed every allowed turn without returning: the
|
|
533
|
+
# coder exhausted its turn budget. This is a bounded, anticipated
|
|
534
|
+
# outcome with its own identity — never a crash, never a parse
|
|
535
|
+
# failure — so it must not be laundered into ``internal_error``.
|
|
536
|
+
if accumulated_files:
|
|
537
|
+
return self._finalize_accumulated_deliveries(
|
|
538
|
+
accumulated_files, test_governing_mutations,
|
|
539
|
+
)
|
|
540
|
+
self._raise_turn_budget_exhausted(finish_reason)
|
|
541
|
+
|
|
542
|
+
# Early break (a content-less response with no tool calls), not turn
|
|
543
|
+
# exhaustion. Try the last assistant content for a legacy free-text
|
|
544
|
+
# delivery, then fail loudly.
|
|
545
|
+
for m in reversed(messages):
|
|
546
|
+
if m.get("role") == "assistant" and m.get("content"):
|
|
547
|
+
return self._try_parse_or_fail(
|
|
548
|
+
m["content"], self.max_tool_turns - 1, finish_reason,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# No content at all — diagnostic failure
|
|
552
|
+
self._raise_tool_loop_failure(
|
|
553
|
+
"", self.max_tool_turns, finish_reason,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def _try_parse_or_fail(
|
|
557
|
+
self, content: str, turns_used: int, finish_reason: Optional[str],
|
|
558
|
+
) -> str:
|
|
559
|
+
"""Attempt to parse content as file operations; raise diagnostic if not."""
|
|
560
|
+
parsed = self._extract_json(content)
|
|
561
|
+
if parsed is not None and isinstance(parsed, list):
|
|
562
|
+
# Validate it looks like file operations
|
|
563
|
+
if all(isinstance(item, dict) for item in parsed):
|
|
564
|
+
return content
|
|
565
|
+
self._raise_tool_loop_failure(content, turns_used, finish_reason)
|
|
566
|
+
|
|
567
|
+
def _raise_tool_loop_failure(
|
|
568
|
+
self, content: str, turns_used: int, finish_reason: Optional[str],
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Raise a diagnostic ParseError when the tool loop ends without files."""
|
|
571
|
+
preview = content[:200] if content else "(empty)"
|
|
572
|
+
raise ParseError(
|
|
573
|
+
"Coder completed the tool loop without delivering files via "
|
|
574
|
+
"submit_files. Final response was empty or unparseable. "
|
|
575
|
+
f"Model: {self.model}, turns used: {turns_used + 1}, "
|
|
576
|
+
f"finish_reason: {finish_reason}, "
|
|
577
|
+
f"content preview: {preview}"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def _raise_turn_budget_exhausted(self, finish_reason: Optional[str]) -> None:
|
|
581
|
+
"""Raise the distinct turn-budget outcome when the coder runs out of turns.
|
|
582
|
+
|
|
583
|
+
The coder used every allowed tool turn without calling ``submit_files``.
|
|
584
|
+
This is not a crash and not an unparseable response; it is a bounded,
|
|
585
|
+
anticipated outcome the engine reports under its own halt outcome.
|
|
586
|
+
"""
|
|
587
|
+
raise TurnBudgetExhausted(
|
|
588
|
+
"Coder exhausted its turn budget "
|
|
589
|
+
f"({self.max_tool_turns} turns) without submitting files via "
|
|
590
|
+
"submit_files. Model: "
|
|
591
|
+
f"{self.model}, finish_reason: {finish_reason}."
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
_SUBMIT_FILES_DEF = {
|
|
595
|
+
"type": "function",
|
|
596
|
+
"function": {
|
|
597
|
+
"name": "submit_files",
|
|
598
|
+
"description": (
|
|
599
|
+
"Submit file operations. You may call this tool multiple times across turns "
|
|
600
|
+
"to stage or update files. File operations accumulate by path across calls and "
|
|
601
|
+
"are applied atomically when complete. Each file has path, optional content "
|
|
602
|
+
"(required for write, optional for delete), and an optional action (\"write\" or \"delete\"). "
|
|
603
|
+
"Use action=\"delete\" to remove obsolete or orphaned files."
|
|
604
|
+
),
|
|
605
|
+
"parameters": {
|
|
606
|
+
"type": "object",
|
|
607
|
+
"properties": {
|
|
608
|
+
"files": {
|
|
609
|
+
"type": "array",
|
|
610
|
+
"items": {
|
|
611
|
+
"type": "object",
|
|
612
|
+
"properties": {
|
|
613
|
+
"path": {
|
|
614
|
+
"type": "string",
|
|
615
|
+
"description": "File path relative to project root",
|
|
616
|
+
},
|
|
617
|
+
"content": {
|
|
618
|
+
"type": "string",
|
|
619
|
+
"description": "Full file content (required for write, optional for delete)",
|
|
620
|
+
},
|
|
621
|
+
"action": {
|
|
622
|
+
"type": "string",
|
|
623
|
+
"enum": ["write", "delete"],
|
|
624
|
+
"description": "write (default) or delete. Use delete to remove orphaned or obsolete files.",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
"required": ["path"],
|
|
628
|
+
},
|
|
629
|
+
"description": "Array of file operations",
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
"required": ["files"],
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _extract_submit_files(target: Any) -> Optional[List[Dict]]:
|
|
639
|
+
"""Extract files list from a submit_files tool call object or tool_calls list.
|
|
640
|
+
|
|
641
|
+
Returns None if submit_files is not present or has invalid/unparseable arguments.
|
|
642
|
+
"""
|
|
643
|
+
if target is None:
|
|
644
|
+
return None
|
|
645
|
+
if isinstance(target, list):
|
|
646
|
+
for tc in target:
|
|
647
|
+
res = LiteLLMAdapter._extract_submit_files(tc)
|
|
648
|
+
if res is not None:
|
|
649
|
+
return res
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
name = getattr(getattr(target, "function", None), "name", None)
|
|
653
|
+
if name != "submit_files":
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
raw_args = getattr(target.function, "arguments", "{}")
|
|
658
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
|
|
659
|
+
except (json.JSONDecodeError, TypeError):
|
|
660
|
+
return None
|
|
661
|
+
|
|
662
|
+
files = args.get("files", [])
|
|
663
|
+
if isinstance(files, list):
|
|
664
|
+
return files
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
@staticmethod
|
|
668
|
+
def _submit_files_feedback(tc: Any) -> str:
|
|
669
|
+
"""Build a tool response for a submit_files call that is not a valid
|
|
670
|
+
completion (zero files or unparseable arguments).
|
|
671
|
+
|
|
672
|
+
The response tells the model to actually produce files; it is appended
|
|
673
|
+
as a tool message so the tool_call_id is always answered before the
|
|
674
|
+
next request.
|
|
675
|
+
"""
|
|
676
|
+
try:
|
|
677
|
+
args = json.loads(tc.function.arguments)
|
|
678
|
+
except (json.JSONDecodeError, TypeError):
|
|
679
|
+
args = {}
|
|
680
|
+
files = args.get("files", [])
|
|
681
|
+
if isinstance(files, list) and not files:
|
|
682
|
+
return (
|
|
683
|
+
"submit_files was called with an empty files list. "
|
|
684
|
+
"You must deliver at least one file operation. "
|
|
685
|
+
"Produce the required files and call submit_files(files=[...]) "
|
|
686
|
+
"with the actual file operations."
|
|
687
|
+
)
|
|
688
|
+
return (
|
|
689
|
+
"submit_files arguments were invalid or unparseable. "
|
|
690
|
+
"Call submit_files(files=[...]) with a valid JSON array of "
|
|
691
|
+
"file operations."
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _check_truncation(self, response: Any) -> None:
|
|
695
|
+
"""Raise ParseError if the completion was truncated at max_tokens."""
|
|
696
|
+
try:
|
|
697
|
+
choice = response.choices[0]
|
|
698
|
+
finish = getattr(choice, "finish_reason", None)
|
|
699
|
+
if finish in self.TRUNCATION_REASONS:
|
|
700
|
+
raw = str(getattr(choice.message, "content", "") or "")
|
|
701
|
+
usage = getattr(response, "usage", None)
|
|
702
|
+
tokens = None
|
|
703
|
+
if usage:
|
|
704
|
+
c_tok = getattr(usage, "completion_tokens", None)
|
|
705
|
+
o_tok = getattr(usage, "output_tokens", None)
|
|
706
|
+
if isinstance(c_tok, int):
|
|
707
|
+
tokens = c_tok
|
|
708
|
+
elif isinstance(o_tok, int):
|
|
709
|
+
tokens = o_tok
|
|
710
|
+
|
|
711
|
+
gen_info = f"{tokens} tokens ({len(raw)} chars)" if tokens is not None else f"{len(raw)} chars"
|
|
712
|
+
|
|
713
|
+
_logger.warning(
|
|
714
|
+
"Coder output truncated at max_tokens=%s — "
|
|
715
|
+
"generated %s before truncation — raw response (first 2KB): %s",
|
|
716
|
+
self.max_tokens,
|
|
717
|
+
gen_info,
|
|
718
|
+
_truncated_log(raw),
|
|
719
|
+
)
|
|
720
|
+
# Report what was observed; label inference as inference
|
|
721
|
+
# (Fixes #67). The observed facts are the finish_reason and
|
|
722
|
+
# the generated token/char counts. "The task is too large" is
|
|
723
|
+
# an inference, not an observation: the same finish_reason
|
|
724
|
+
# occurs when a tool call's arguments exceed the output budget
|
|
725
|
+
# and are cut off mid-argument — a different fault with a
|
|
726
|
+
# different remedy.
|
|
727
|
+
raise ParseError(
|
|
728
|
+
f"Coder output stopped at max_tokens={self.max_tokens} "
|
|
729
|
+
f"(finish_reason={finish}); generated {gen_info}. "
|
|
730
|
+
f"Observed: the response was cut off at the token ceiling. "
|
|
731
|
+
f"Inference (not confirmed): the task is too large, or a "
|
|
732
|
+
f"tool call's arguments exceeded the output budget and "
|
|
733
|
+
f"were cut off mid-argument. Raise max_tokens or split "
|
|
734
|
+
f"the task."
|
|
735
|
+
)
|
|
736
|
+
except (AttributeError, IndexError):
|
|
737
|
+
pass
|
|
738
|
+
|
|
739
|
+
@staticmethod
|
|
740
|
+
def _build_tool_definitions() -> List[Dict[str, Any]]:
|
|
741
|
+
"""Build OpenAI-format tool definitions for the read-only toolset."""
|
|
742
|
+
return [
|
|
743
|
+
{
|
|
744
|
+
"type": "function",
|
|
745
|
+
"function": {
|
|
746
|
+
"name": "read_files",
|
|
747
|
+
"description": "Read full contents of multiple files in a single tool call (turns are expensive, use this to read multiple files at once)",
|
|
748
|
+
"parameters": {
|
|
749
|
+
"type": "object",
|
|
750
|
+
"properties": {
|
|
751
|
+
"paths": {
|
|
752
|
+
"type": "array",
|
|
753
|
+
"items": {"type": "string"},
|
|
754
|
+
"description": "File paths relative to project root",
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
"required": ["paths"],
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
"type": "function",
|
|
763
|
+
"function": {
|
|
764
|
+
"name": "read_file",
|
|
765
|
+
"description": "Read full file content",
|
|
766
|
+
"parameters": {
|
|
767
|
+
"type": "object",
|
|
768
|
+
"properties": {
|
|
769
|
+
"path": {"type": "string", "description": "File path relative to project root"},
|
|
770
|
+
},
|
|
771
|
+
"required": ["path"],
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
"type": "function",
|
|
777
|
+
"function": {
|
|
778
|
+
"name": "read_file_lines",
|
|
779
|
+
"description": "Read a line range from a file (1-indexed, inclusive)",
|
|
780
|
+
"parameters": {
|
|
781
|
+
"type": "object",
|
|
782
|
+
"properties": {
|
|
783
|
+
"path": {"type": "string", "description": "File path relative to project root"},
|
|
784
|
+
"start": {"type": "integer", "description": "First line number (1-indexed)"},
|
|
785
|
+
"end": {"type": "integer", "description": "Last line number (1-indexed, inclusive)"},
|
|
786
|
+
},
|
|
787
|
+
"required": ["path", "start", "end"],
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
"type": "function",
|
|
793
|
+
"function": {
|
|
794
|
+
"name": "list_files",
|
|
795
|
+
"description": "List files and directories in a directory",
|
|
796
|
+
"parameters": {
|
|
797
|
+
"type": "object",
|
|
798
|
+
"properties": {
|
|
799
|
+
"directory": {"type": "string", "description": "Directory path", "default": "."},
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
"type": "function",
|
|
806
|
+
"function": {
|
|
807
|
+
"name": "search_string",
|
|
808
|
+
"description": (
|
|
809
|
+
"Search inside file contents for a literal string. `query` is the TEXT "
|
|
810
|
+
"to find — it is never a file or directory path. To restrict the search "
|
|
811
|
+
"to a folder, pass that folder as `directory`. To see what a folder "
|
|
812
|
+
"contains, use list_files instead."
|
|
813
|
+
),
|
|
814
|
+
"parameters": {
|
|
815
|
+
"type": "object",
|
|
816
|
+
"properties": {
|
|
817
|
+
"query": {
|
|
818
|
+
"type": "string",
|
|
819
|
+
"description": "Literal text to find inside file contents. NOT a path — file and directory paths belong in `directory`.",
|
|
820
|
+
},
|
|
821
|
+
"directory": {
|
|
822
|
+
"type": "string",
|
|
823
|
+
"description": "Optional folder to search inside, relative to project root (e.g. \"src\"). Defaults to \".\" (the whole project).",
|
|
824
|
+
"default": ".",
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
"required": ["query"],
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
"type": "function",
|
|
833
|
+
"function": {
|
|
834
|
+
"name": "search_symbol",
|
|
835
|
+
"description": (
|
|
836
|
+
"Find where a symbol is defined (class, def, function, struct, fn, type, "
|
|
837
|
+
"const). `name` is an identifier such as \"AuthManager\" — it is never a "
|
|
838
|
+
"file or directory path. To restrict the search to a folder, pass that "
|
|
839
|
+
"folder as `directory`."
|
|
840
|
+
),
|
|
841
|
+
"parameters": {
|
|
842
|
+
"type": "object",
|
|
843
|
+
"properties": {
|
|
844
|
+
"name": {
|
|
845
|
+
"type": "string",
|
|
846
|
+
"description": "Symbol identifier to locate (e.g. \"parse_config\"). NOT a path — file and directory paths belong in `directory`.",
|
|
847
|
+
},
|
|
848
|
+
"directory": {
|
|
849
|
+
"type": "string",
|
|
850
|
+
"description": "Optional folder to search inside, relative to project root (e.g. \"src\"). Defaults to \".\" (the whole project).",
|
|
851
|
+
"default": ".",
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
"required": ["name"],
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
"type": "function",
|
|
860
|
+
"function": {
|
|
861
|
+
"name": "run_tests",
|
|
862
|
+
"description": "Run the project's declared test runner (pytest, npm, cargo) to observe test output before submitting edits",
|
|
863
|
+
"parameters": {
|
|
864
|
+
"type": "object",
|
|
865
|
+
"properties": {
|
|
866
|
+
"test_path": {"type": "string", "description": "Path to test file or directory (relative to project root)", "default": ""},
|
|
867
|
+
"command_type": {"type": "string", "enum": ["pytest", "npm", "cargo"], "default": "pytest", "description": "Test command type"},
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
]
|
|
873
|
+
|
|
874
|
+
@staticmethod
|
|
875
|
+
def _execute_tool(
|
|
876
|
+
name: str,
|
|
877
|
+
args: Dict[str, Any],
|
|
878
|
+
workspace: Any,
|
|
879
|
+
) -> str:
|
|
880
|
+
"""Execute a read-only tool call and return the result as a string."""
|
|
881
|
+
try:
|
|
882
|
+
if name == "read_files":
|
|
883
|
+
paths = args.get("paths", [])
|
|
884
|
+
if not isinstance(paths, list) or not paths:
|
|
885
|
+
return "No paths provided to read_files."
|
|
886
|
+
|
|
887
|
+
max_paths = 10
|
|
888
|
+
max_bytes = 32768
|
|
889
|
+
|
|
890
|
+
requested_paths = [str(p) for p in paths]
|
|
891
|
+
processed_paths = requested_paths[:max_paths]
|
|
892
|
+
omitted_paths = requested_paths[max_paths:]
|
|
893
|
+
|
|
894
|
+
results = []
|
|
895
|
+
current_bytes = 0
|
|
896
|
+
truncated = False
|
|
897
|
+
|
|
898
|
+
for idx, p in enumerate(processed_paths):
|
|
899
|
+
try:
|
|
900
|
+
content = workspace.read_file(p)
|
|
901
|
+
header = f"=== {p} ===\n"
|
|
902
|
+
entry = header + content
|
|
903
|
+
entry_bytes = len(entry.encode("utf-8"))
|
|
904
|
+
|
|
905
|
+
if current_bytes + entry_bytes > max_bytes:
|
|
906
|
+
allowed_content_bytes = max_bytes - current_bytes - len(header.encode("utf-8")) - 100
|
|
907
|
+
if allowed_content_bytes > 0:
|
|
908
|
+
truncated_content = content.encode("utf-8")[:allowed_content_bytes].decode("utf-8", errors="ignore")
|
|
909
|
+
results.append(f"{header}{truncated_content}\n[TRUNCATED: Content cut off at 32KB batch limit]")
|
|
910
|
+
else:
|
|
911
|
+
results.append(f"{header}[OMITTED: Exceeded 32KB batch limit]")
|
|
912
|
+
truncated = True
|
|
913
|
+
omitted_paths.extend(processed_paths[idx + 1:])
|
|
914
|
+
break
|
|
915
|
+
else:
|
|
916
|
+
results.append(entry)
|
|
917
|
+
current_bytes += entry_bytes
|
|
918
|
+
except Exception as e:
|
|
919
|
+
results.append(f"=== {p} ===\nError reading file: {e}")
|
|
920
|
+
|
|
921
|
+
output = "\n\n".join(results)
|
|
922
|
+
if omitted_paths or truncated:
|
|
923
|
+
notice_parts = []
|
|
924
|
+
if omitted_paths:
|
|
925
|
+
notice_parts.append(f"{len(omitted_paths)} path(s) omitted ({', '.join(omitted_paths)})")
|
|
926
|
+
if truncated:
|
|
927
|
+
notice_parts.append("content truncated at 32KB batch limit")
|
|
928
|
+
output += f"\n\n[TRUNCATED BATCH: {'; '.join(notice_parts)}. Use read_file_lines(path, start, end) for targeted line ranges.]"
|
|
929
|
+
return output
|
|
930
|
+
elif name == "read_file":
|
|
931
|
+
return workspace.read_file(args["path"])
|
|
932
|
+
elif name == "read_file_lines":
|
|
933
|
+
return workspace.read_file_lines(args["path"], args["start"], args["end"])
|
|
934
|
+
elif name == "list_files":
|
|
935
|
+
return "\n".join(workspace.list_files(args.get("directory", ".")))
|
|
936
|
+
elif name == "search_string":
|
|
937
|
+
query = args.get("query", "")
|
|
938
|
+
directory = args.get("directory", ".")
|
|
939
|
+
misuse = _search_term_directory_guidance(workspace, "search_string", "query", query, directory)
|
|
940
|
+
if misuse is not None:
|
|
941
|
+
return misuse
|
|
942
|
+
if hasattr(workspace, "search_string"):
|
|
943
|
+
return workspace.search_string(query, directory)
|
|
944
|
+
return f"search_string unavailable on workspace: {workspace}"
|
|
945
|
+
elif name == "search_symbol":
|
|
946
|
+
symbol_name = args.get("name", "")
|
|
947
|
+
directory = args.get("directory", ".")
|
|
948
|
+
misuse = _search_term_directory_guidance(workspace, "search_symbol", "name", symbol_name, directory)
|
|
949
|
+
if misuse is not None:
|
|
950
|
+
return misuse
|
|
951
|
+
if hasattr(workspace, "search_symbol"):
|
|
952
|
+
return workspace.search_symbol(symbol_name, directory)
|
|
953
|
+
return f"search_symbol unavailable on workspace: {workspace}"
|
|
954
|
+
else:
|
|
955
|
+
return f"Unknown tool: {name}"
|
|
956
|
+
except Exception as e:
|
|
957
|
+
return f"Tool error: {e}"
|
|
958
|
+
|
|
959
|
+
def _execute_coder_run_tests(
|
|
960
|
+
self, test_path: str, command_type: str, workspace: Any, turn_index: int
|
|
961
|
+
) -> str:
|
|
962
|
+
"""Execute coder-facing test run (ADR 040 Point 1, 2, 4, 8, 10)."""
|
|
963
|
+
project_root = getattr(workspace, "project_root", None)
|
|
964
|
+
if not project_root:
|
|
965
|
+
return "Test runner unavailable: workspace project root not found."
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
from snodo.tools.shell import ShellMCP
|
|
969
|
+
shell = ShellMCP(str(project_root))
|
|
970
|
+
res = shell.run_tests_raw(test_path=test_path, command_type=command_type, timeout=120)
|
|
971
|
+
except Exception as e:
|
|
972
|
+
return f"Test execution failed to launch: {e}"
|
|
973
|
+
|
|
974
|
+
self._emit_audit_event(
|
|
975
|
+
"coder_test_run",
|
|
976
|
+
{
|
|
977
|
+
"turn_index": turn_index,
|
|
978
|
+
"test_path": test_path,
|
|
979
|
+
"command_type": command_type,
|
|
980
|
+
"exit_code": res.get("exit_code", -1),
|
|
981
|
+
"job_id": self._job_id,
|
|
982
|
+
"task_id": self._task_id,
|
|
983
|
+
},
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
exit_code = res.get("exit_code", -1)
|
|
987
|
+
stdout = res.get("stdout", "")
|
|
988
|
+
stderr = res.get("stderr", "")
|
|
989
|
+
|
|
990
|
+
return (
|
|
991
|
+
f"Test Execution Result (exit code: {exit_code}):\n"
|
|
992
|
+
f"--- STDOUT ---\n{stdout}\n"
|
|
993
|
+
f"--- STDERR ---\n{stderr}"
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def _emit_audit_event(self, event_type: str, data: Dict[str, Any]) -> None:
|
|
997
|
+
"""Emit an audit event if audit_log is available."""
|
|
998
|
+
try:
|
|
999
|
+
from snodo.infrastructure.audit import get_audit_log
|
|
1000
|
+
audit_log = get_audit_log()
|
|
1001
|
+
if audit_log and hasattr(audit_log, "append_event"):
|
|
1002
|
+
audit_log.append_event(event_type, data)
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
_logger.warning("Audit event logging failed: %s", e)
|
|
1005
|
+
|
|
1006
|
+
def _emit_turn_telemetry(
|
|
1007
|
+
self,
|
|
1008
|
+
turn_index: int,
|
|
1009
|
+
tool: str,
|
|
1010
|
+
target_path: str,
|
|
1011
|
+
read_hit: bool,
|
|
1012
|
+
tokens_in: int,
|
|
1013
|
+
tokens_out: int,
|
|
1014
|
+
elapsed_ms: float,
|
|
1015
|
+
submit_bytes: int = 0,
|
|
1016
|
+
) -> None:
|
|
1017
|
+
"""Emit one per-turn telemetry record to the job's state.json.
|
|
1018
|
+
|
|
1019
|
+
Operational telemetry, not part of the audit chain (ADR 034). Never
|
|
1020
|
+
raises — telemetry must not crash the tool loop.
|
|
1021
|
+
"""
|
|
1022
|
+
try:
|
|
1023
|
+
from snodo.infrastructure.tool_telemetry import (
|
|
1024
|
+
canonical_target_path,
|
|
1025
|
+
persist_tool_telemetry,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
record = {
|
|
1029
|
+
"task_ref": self._task_id or "unknown",
|
|
1030
|
+
"depth": getattr(self, "_depth", 0) or 0,
|
|
1031
|
+
"attempt": getattr(self, "_attempt", 1) or 1,
|
|
1032
|
+
"role": "coder",
|
|
1033
|
+
"validator_id": "",
|
|
1034
|
+
"turn_index": turn_index,
|
|
1035
|
+
"tool": tool,
|
|
1036
|
+
"target_path": canonical_target_path(target_path),
|
|
1037
|
+
"read_hit": bool(read_hit),
|
|
1038
|
+
"tokens_in": int(tokens_in or 0),
|
|
1039
|
+
"tokens_out": int(tokens_out or 0),
|
|
1040
|
+
"elapsed_ms": round(float(elapsed_ms or 0), 1),
|
|
1041
|
+
"submit_bytes": int(submit_bytes or 0),
|
|
1042
|
+
}
|
|
1043
|
+
persist_tool_telemetry(self._job_id or "unknown", record)
|
|
1044
|
+
except Exception as e:
|
|
1045
|
+
_logger.warning("Failed to persist tool telemetry: %s", e)
|
|
1046
|
+
|
|
1047
|
+
def _parse_response(self, response: str) -> CodeArtifact:
|
|
1048
|
+
parsed = self._extract_json(response)
|
|
1049
|
+
|
|
1050
|
+
if parsed is None or not isinstance(parsed, list):
|
|
1051
|
+
_logger.warning(
|
|
1052
|
+
"Coder parse failure — raw response (first 2KB): %s",
|
|
1053
|
+
_truncated_log(response),
|
|
1054
|
+
)
|
|
1055
|
+
raise ParseError(
|
|
1056
|
+
"Failed to parse response as JSON array of file operations"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
files = []
|
|
1060
|
+
for item in parsed:
|
|
1061
|
+
if not isinstance(item, dict):
|
|
1062
|
+
raise ParseError(f"Expected dict in file operations array, got {type(item).__name__}")
|
|
1063
|
+
action = item.get("action", "write")
|
|
1064
|
+
if "path" not in item or (action != "delete" and "content" not in item):
|
|
1065
|
+
raise ParseError(
|
|
1066
|
+
f"Each file operation must have 'path' (and 'content' for write operations). Got keys: {list(item.keys())}"
|
|
1067
|
+
)
|
|
1068
|
+
rel_parts = Path(item["path"]).parts
|
|
1069
|
+
if rel_parts and rel_parts[0] == ".snodo":
|
|
1070
|
+
_logger.warning("Excluded protected path under .snodo/ from coder artifacts: %s", item["path"])
|
|
1071
|
+
continue
|
|
1072
|
+
files.append(FileArtifact(
|
|
1073
|
+
path=item["path"],
|
|
1074
|
+
content=item.get("content") or "",
|
|
1075
|
+
action=action,
|
|
1076
|
+
))
|
|
1077
|
+
|
|
1078
|
+
metadata = getattr(self, "_last_artifact_metadata", {}) or {}
|
|
1079
|
+
return CodeArtifact(files=files, metadata=metadata)
|
|
1080
|
+
|
|
1081
|
+
@staticmethod
|
|
1082
|
+
def _extract_json(response: str):
|
|
1083
|
+
"""Extract JSON array from raw response or code block.
|
|
1084
|
+
|
|
1085
|
+
Uses a greedy fence extractor so that ``` inside file content does
|
|
1086
|
+
not break the match — only the outermost ``` fence pair is consumed.
|
|
1087
|
+
"""
|
|
1088
|
+
try:
|
|
1089
|
+
return json.loads(response)
|
|
1090
|
+
except (json.JSONDecodeError, TypeError):
|
|
1091
|
+
pass
|
|
1092
|
+
|
|
1093
|
+
# Greedy: match from the first ``` fence to the LAST ``` fence,
|
|
1094
|
+
# stripping only the outermost pair. The non-greedy .*? would stop
|
|
1095
|
+
# at the first ``` inside file content.
|
|
1096
|
+
match = re.search(
|
|
1097
|
+
r'```(?:json)?\s*\n(.*)```\s*$', response, re.DOTALL
|
|
1098
|
+
)
|
|
1099
|
+
if match:
|
|
1100
|
+
try:
|
|
1101
|
+
return json.loads(match.group(1).strip())
|
|
1102
|
+
except (json.JSONDecodeError, TypeError):
|
|
1103
|
+
pass
|
|
1104
|
+
|
|
1105
|
+
return None
|
|
1106
|
+
|
|
1107
|
+
def attach_mcp_tool(self, mcp_server: MCPServer) -> None:
|
|
1108
|
+
if mcp_server not in self.mcp_servers:
|
|
1109
|
+
self.mcp_servers.append(mcp_server)
|
|
1110
|
+
|
|
1111
|
+
def list_available_tools(self) -> List[str]:
|
|
1112
|
+
return [f"mcp_server_{i}" for i in range(len(self.mcp_servers))]
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _truncated_log(raw: str, max_chars: int = 2048) -> str:
|
|
1116
|
+
"""Truncate a raw response string for logging."""
|
|
1117
|
+
if len(raw) <= max_chars:
|
|
1118
|
+
return raw
|
|
1119
|
+
return raw[:max_chars] + "...<truncated>"
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _usage_tokens(response: Any, kind: str) -> int:
|
|
1123
|
+
"""Extract prompt/completion token counts from a litellm response.
|
|
1124
|
+
|
|
1125
|
+
Returns 0 when the response carries no usage (e.g. mock responses).
|
|
1126
|
+
"""
|
|
1127
|
+
try:
|
|
1128
|
+
usage = getattr(response, "usage", None)
|
|
1129
|
+
if usage is None:
|
|
1130
|
+
return 0
|
|
1131
|
+
if kind == "prompt":
|
|
1132
|
+
return int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
1133
|
+
return int(getattr(usage, "completion_tokens", 0) or 0)
|
|
1134
|
+
except Exception:
|
|
1135
|
+
return 0
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _search_term_directory_guidance(
|
|
1139
|
+
workspace: Any, tool_name: str, term_key: str, term: Any, directory: Any,
|
|
1140
|
+
) -> Optional[str]:
|
|
1141
|
+
"""Return a correction message when the search-term slot holds a directory path.
|
|
1142
|
+
|
|
1143
|
+
ADR 040 tools shipped with a transcript where the coder called
|
|
1144
|
+
search_string(tests), search_string(src/scripts) — ninety turns of
|
|
1145
|
+
full-tree scans for directory NAMES (Fixes #184). The value in the term
|
|
1146
|
+
slot resolves to an existing directory and no explicit folder was given,
|
|
1147
|
+
so the model was almost certainly naming where to look, not what to find.
|
|
1148
|
+
Answering with guidance instead of a scan bounds the loop: one wrong call
|
|
1149
|
+
costs one turn, and the repeated call dedupes from there.
|
|
1150
|
+
"""
|
|
1151
|
+
term_str = str(term or "").strip()
|
|
1152
|
+
if not term_str:
|
|
1153
|
+
return None
|
|
1154
|
+
dir_str = str(directory if directory is not None else ".").strip()
|
|
1155
|
+
if dir_str and dir_str not in (".", "./", term_str):
|
|
1156
|
+
# An explicit folder alongside the term: a legitimate scoped search
|
|
1157
|
+
# for text that happens to look like a path fragment.
|
|
1158
|
+
return None
|
|
1159
|
+
validator = getattr(workspace, "validate_path", None)
|
|
1160
|
+
if validator is None:
|
|
1161
|
+
return None
|
|
1162
|
+
try:
|
|
1163
|
+
resolved = validator(term_str)
|
|
1164
|
+
except Exception:
|
|
1165
|
+
return None
|
|
1166
|
+
if not isinstance(resolved, Path):
|
|
1167
|
+
return None
|
|
1168
|
+
try:
|
|
1169
|
+
is_dir = bool(resolved.is_dir())
|
|
1170
|
+
except Exception:
|
|
1171
|
+
is_dir = False
|
|
1172
|
+
if not is_dir:
|
|
1173
|
+
return None
|
|
1174
|
+
return (
|
|
1175
|
+
f"{tool_name} searches inside file contents: `{term_key}` is the text to find, not a path. "
|
|
1176
|
+
f"'{term_str}' is a directory in this project, so nothing was searched. "
|
|
1177
|
+
f"To search inside it, call {tool_name}({term_key}=<text to find>, directory=\"{term_str}\"). "
|
|
1178
|
+
f"To see what it contains, call list_files(directory=\"{term_str}\"). "
|
|
1179
|
+
f"To find where a symbol is defined, call search_symbol(name=<identifier>)."
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _canonical_rel_path(value: Any, project_root: Optional[Path] = None) -> str:
|
|
1184
|
+
"""Normalize a path argument the same way the workspace resolves it.
|
|
1185
|
+
|
|
1186
|
+
Deduplication must not depend on spelling: './x', 'x/', 'a//b', 'a\\b' and
|
|
1187
|
+
an absolute path under the project root all address one file, and the
|
|
1188
|
+
resolver treats them as one, so the dedup key must too (Fixes #184).
|
|
1189
|
+
"""
|
|
1190
|
+
p = str(value).replace("\\", "/").strip()
|
|
1191
|
+
if not p:
|
|
1192
|
+
return "."
|
|
1193
|
+
path = Path(p)
|
|
1194
|
+
if project_root is not None and path.is_absolute():
|
|
1195
|
+
try:
|
|
1196
|
+
path = path.resolve().relative_to(project_root)
|
|
1197
|
+
except (ValueError, OSError) as e:
|
|
1198
|
+
# Outside the root (or unresolvable): keep the absolute form so
|
|
1199
|
+
# the dedup key is at least stable across calls.
|
|
1200
|
+
_logger.debug("Dedup key kept absolute for path outside root %s: %s", project_root, e)
|
|
1201
|
+
return path.as_posix()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _normalize_path_arg(args: Dict[str, Any], project_root: Optional[Path] = None) -> str:
|
|
1205
|
+
"""Extract and normalize a path argument string."""
|
|
1206
|
+
for path_key in ("path", "directory", "file_path", "target_file", "file"):
|
|
1207
|
+
val = args.get(path_key)
|
|
1208
|
+
if val and isinstance(val, str):
|
|
1209
|
+
if val.strip():
|
|
1210
|
+
return _canonical_rel_path(val, project_root)
|
|
1211
|
+
return ""
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _extract_line_range(tool_name: str, args: Dict[str, Any]) -> Tuple[int, float]:
|
|
1215
|
+
"""Extract (start_line, end_line) range from tool args."""
|
|
1216
|
+
if tool_name in ("read_file", "read_files"):
|
|
1217
|
+
return (1, float("inf"))
|
|
1218
|
+
|
|
1219
|
+
if tool_name in ("read_file_lines", "read_lines"):
|
|
1220
|
+
start = args.get("start") or args.get("start_line") or 1
|
|
1221
|
+
end = args.get("end") or args.get("end_line") or float("inf")
|
|
1222
|
+
try:
|
|
1223
|
+
start_int = int(start)
|
|
1224
|
+
except (ValueError, TypeError):
|
|
1225
|
+
start_int = 1
|
|
1226
|
+
try:
|
|
1227
|
+
end_num = float(end)
|
|
1228
|
+
except (ValueError, TypeError):
|
|
1229
|
+
end_num = float("inf")
|
|
1230
|
+
return (start_int, end_num)
|
|
1231
|
+
|
|
1232
|
+
return (1, float("inf"))
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
_READ_TOOLS = frozenset({
|
|
1236
|
+
"read_files",
|
|
1237
|
+
"read_file",
|
|
1238
|
+
"read_file_lines",
|
|
1239
|
+
"read_lines",
|
|
1240
|
+
"list_files",
|
|
1241
|
+
"list_directory",
|
|
1242
|
+
"ls",
|
|
1243
|
+
"git_show",
|
|
1244
|
+
"read_diff_between_refs",
|
|
1245
|
+
"git_log",
|
|
1246
|
+
"search_symbol",
|
|
1247
|
+
"search_string",
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
class ReadMemoryTracker:
|
|
1252
|
+
"""Tracks read file contents, line ranges, directory listings, and tool arguments across turns."""
|
|
1253
|
+
|
|
1254
|
+
def __init__(self, project_root: Optional[Any] = None) -> None:
|
|
1255
|
+
#: Same root the workspace resolves against; lets dedup keys treat
|
|
1256
|
+
#: every spelling of one path as one path (Fixes #184).
|
|
1257
|
+
self._project_root: Optional[Path] = None
|
|
1258
|
+
if project_root:
|
|
1259
|
+
try:
|
|
1260
|
+
self._project_root = Path(str(project_root)).resolve()
|
|
1261
|
+
except Exception:
|
|
1262
|
+
self._project_root = None
|
|
1263
|
+
# Exact canonical (tool_name, norm_args_json) -> turn_idx (1-based)
|
|
1264
|
+
self.exact_reads: Dict[Tuple[str, str], int] = {}
|
|
1265
|
+
# file_path -> [(start_line, end_line, turn_idx), ...]
|
|
1266
|
+
self.file_ranges: Dict[str, List[Tuple[int, float, int]]] = {}
|
|
1267
|
+
# canonical dir_path -> turn_idx
|
|
1268
|
+
self.dir_listings: Dict[str, int] = {}
|
|
1269
|
+
|
|
1270
|
+
def check_read(self, tool_name: str, args: Dict[str, Any]) -> Optional[int]:
|
|
1271
|
+
"""Check if a read tool call is already covered by memory.
|
|
1272
|
+
|
|
1273
|
+
Returns the 1-based turn_idx if covered, else None.
|
|
1274
|
+
"""
|
|
1275
|
+
if tool_name not in _READ_TOOLS:
|
|
1276
|
+
return None
|
|
1277
|
+
|
|
1278
|
+
# 1. Exact argument match
|
|
1279
|
+
exact_key = _canonical_read_key(tool_name, args, self._project_root)
|
|
1280
|
+
if exact_key and exact_key in self.exact_reads:
|
|
1281
|
+
return self.exact_reads[exact_key]
|
|
1282
|
+
|
|
1283
|
+
# 2. Batch read check
|
|
1284
|
+
if tool_name == "read_files":
|
|
1285
|
+
paths = args.get("paths", [])
|
|
1286
|
+
if isinstance(paths, list) and paths:
|
|
1287
|
+
all_read = True
|
|
1288
|
+
first_turn = None
|
|
1289
|
+
for p in paths:
|
|
1290
|
+
norm_p = _canonical_rel_path(p, self._project_root) if isinstance(p, str) and p.strip() else ""
|
|
1291
|
+
if not norm_p or norm_p not in self.file_ranges:
|
|
1292
|
+
all_read = False
|
|
1293
|
+
break
|
|
1294
|
+
if first_turn is None:
|
|
1295
|
+
first_turn = self.file_ranges[norm_p][0][2]
|
|
1296
|
+
if all_read:
|
|
1297
|
+
return first_turn
|
|
1298
|
+
|
|
1299
|
+
# 3. File line range / whole file coverage check
|
|
1300
|
+
if tool_name in ("read_file", "read_file_lines", "read_lines"):
|
|
1301
|
+
path = _normalize_path_arg(args, self._project_root)
|
|
1302
|
+
if path and path in self.file_ranges:
|
|
1303
|
+
req_start, req_end = _extract_line_range(tool_name, args)
|
|
1304
|
+
for cov_start, cov_end, turn_idx in self.file_ranges[path]:
|
|
1305
|
+
if cov_start <= req_start and cov_end >= req_end:
|
|
1306
|
+
return turn_idx
|
|
1307
|
+
|
|
1308
|
+
# 4. Directory listing check
|
|
1309
|
+
if tool_name in ("list_files", "list_directory", "ls"):
|
|
1310
|
+
dir_path = _normalize_path_arg(args, self._project_root) or "."
|
|
1311
|
+
if dir_path in self.dir_listings:
|
|
1312
|
+
return self.dir_listings[dir_path]
|
|
1313
|
+
|
|
1314
|
+
return None
|
|
1315
|
+
|
|
1316
|
+
def record_read(self, tool_name: str, args: Dict[str, Any], turn_idx: int) -> None:
|
|
1317
|
+
"""Record a successful read tool execution in memory."""
|
|
1318
|
+
exact_key = _canonical_read_key(tool_name, args, self._project_root)
|
|
1319
|
+
if exact_key:
|
|
1320
|
+
self.exact_reads[exact_key] = turn_idx
|
|
1321
|
+
|
|
1322
|
+
if tool_name == "read_files":
|
|
1323
|
+
paths = args.get("paths", [])
|
|
1324
|
+
if isinstance(paths, list):
|
|
1325
|
+
for p in paths:
|
|
1326
|
+
if isinstance(p, str) and p.strip():
|
|
1327
|
+
norm_p = _canonical_rel_path(p, self._project_root)
|
|
1328
|
+
if norm_p not in self.file_ranges:
|
|
1329
|
+
self.file_ranges[norm_p] = []
|
|
1330
|
+
self.file_ranges[norm_p].append((1, float("inf"), turn_idx))
|
|
1331
|
+
|
|
1332
|
+
if tool_name in ("read_file", "read_file_lines", "read_lines"):
|
|
1333
|
+
path = _normalize_path_arg(args, self._project_root)
|
|
1334
|
+
if path:
|
|
1335
|
+
req_start, req_end = _extract_line_range(tool_name, args)
|
|
1336
|
+
if path not in self.file_ranges:
|
|
1337
|
+
self.file_ranges[path] = []
|
|
1338
|
+
self.file_ranges[path].append((req_start, req_end, turn_idx))
|
|
1339
|
+
|
|
1340
|
+
if tool_name in ("list_files", "list_directory", "ls"):
|
|
1341
|
+
dir_path = _normalize_path_arg(args, self._project_root) or "."
|
|
1342
|
+
if dir_path not in self.dir_listings:
|
|
1343
|
+
self.dir_listings[dir_path] = turn_idx
|
|
1344
|
+
|
|
1345
|
+
def export_paths(self) -> Dict[str, List[str]]:
|
|
1346
|
+
"""Return the paths this attempt inspected, WITHOUT their contents.
|
|
1347
|
+
|
|
1348
|
+
``files`` are paths opened via read_file/read_file_lines/read_lines;
|
|
1349
|
+
``directories`` are paths listed via list_files/list_directory/ls.
|
|
1350
|
+
|
|
1351
|
+
Contents are deliberately excluded (Fixes #157 follow-up on recovery
|
|
1352
|
+
cold-start): between attempts the tree changes, and handing a later
|
|
1353
|
+
attempt a cached version of a file it must now fix would make a stale
|
|
1354
|
+
view authoritative — worse than re-reading. Only the map of *where the
|
|
1355
|
+
previous attempt looked* is safe to carry forward.
|
|
1356
|
+
"""
|
|
1357
|
+
return {
|
|
1358
|
+
"files": sorted(self.file_ranges.keys()),
|
|
1359
|
+
"directories": sorted(self.dir_listings.keys()),
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _canonical_read_key(
|
|
1364
|
+
name: str, args: Dict[str, Any], project_root: Optional[Path] = None,
|
|
1365
|
+
) -> Optional[Tuple[str, str]]:
|
|
1366
|
+
"""Return (tool_name, canonical_args_str) for read tools, or None if not a read tool.
|
|
1367
|
+
|
|
1368
|
+
The key must describe the CALL, not the spelling: an optional parameter
|
|
1369
|
+
left absent is the same call as one sent at its default, a null parameter
|
|
1370
|
+
is the same as an absent one, and every path alias the resolver unifies
|
|
1371
|
+
must unify here too — otherwise the dedup that is supposed to bound the
|
|
1372
|
+
search/read loop lets one call become ninety (Fixes #184).
|
|
1373
|
+
"""
|
|
1374
|
+
if name not in _READ_TOOLS:
|
|
1375
|
+
return None
|
|
1376
|
+
|
|
1377
|
+
norm_args: Dict[str, Any] = {}
|
|
1378
|
+
for key, val in args.items():
|
|
1379
|
+
if val is None:
|
|
1380
|
+
continue # a null optional parameter is an omitted one
|
|
1381
|
+
if key == "paths" and isinstance(val, list):
|
|
1382
|
+
norm_args[key] = [
|
|
1383
|
+
_canonical_rel_path(p, project_root)
|
|
1384
|
+
for p in val
|
|
1385
|
+
if isinstance(p, str) and p.strip()
|
|
1386
|
+
]
|
|
1387
|
+
elif key in ("path", "directory", "file_path", "target_file", "file") and isinstance(val, str):
|
|
1388
|
+
norm_args[key] = _canonical_rel_path(val, project_root)
|
|
1389
|
+
else:
|
|
1390
|
+
norm_args[key] = val
|
|
1391
|
+
|
|
1392
|
+
# 'directory' defaults to "." — explicit and implicit are one call.
|
|
1393
|
+
if norm_args.get("directory") == ".":
|
|
1394
|
+
norm_args.pop("directory")
|
|
1395
|
+
|
|
1396
|
+
try:
|
|
1397
|
+
args_str = json.dumps(norm_args, sort_keys=True)
|
|
1398
|
+
except Exception:
|
|
1399
|
+
args_str = str(norm_args)
|
|
1400
|
+
|
|
1401
|
+
return (name, args_str)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def format_repeat_read_response(tool_name: str, args: Dict[str, Any], prev_turn: int) -> str:
|
|
1405
|
+
"""Format a concise tool response pointing to the previous turn containing the result."""
|
|
1406
|
+
if tool_name in ("search_string", "search_symbol"):
|
|
1407
|
+
term_key = "query" if tool_name == "search_string" else "name"
|
|
1408
|
+
term = str((args or {}).get(term_key) or "").strip()
|
|
1409
|
+
if term:
|
|
1410
|
+
return (
|
|
1411
|
+
f"{tool_name} for '{term}' was already run in Turn {prev_turn}. "
|
|
1412
|
+
f"Refer to the tool response from Turn {prev_turn} for its results. "
|
|
1413
|
+
"Repeating this call returns the same output and wastes a turn — "
|
|
1414
|
+
"search for a different term, read a specific file, or proceed to submit_files."
|
|
1415
|
+
)
|
|
1416
|
+
target = _normalize_path_arg(args)
|
|
1417
|
+
if tool_name in ("read_file", "read_files", "read_file_lines", "read_lines"):
|
|
1418
|
+
req_start, req_end = _extract_line_range(tool_name, args)
|
|
1419
|
+
range_str = f"lines {req_start}-{int(req_end)}" if req_end != float("inf") else "full file"
|
|
1420
|
+
if target:
|
|
1421
|
+
return (
|
|
1422
|
+
f"File '{target}' ({range_str}) was already fetched using {tool_name} in Turn {prev_turn}. "
|
|
1423
|
+
f"Refer to the tool response from Turn {prev_turn} for its content."
|
|
1424
|
+
)
|
|
1425
|
+
if tool_name in ("list_files", "list_directory", "ls"):
|
|
1426
|
+
dir_name = target or "."
|
|
1427
|
+
return (
|
|
1428
|
+
f"Directory '{dir_name}' was already listed using {tool_name} in Turn {prev_turn}. "
|
|
1429
|
+
f"Refer to the tool response from Turn {prev_turn} for its contents."
|
|
1430
|
+
)
|
|
1431
|
+
if target:
|
|
1432
|
+
return (
|
|
1433
|
+
f"'{target}' was already fetched using {tool_name} in Turn {prev_turn}. "
|
|
1434
|
+
f"Refer to the tool response from Turn {prev_turn} for its content."
|
|
1435
|
+
)
|
|
1436
|
+
return (
|
|
1437
|
+
f"Tool '{tool_name}' with identical arguments was already executed in Turn {prev_turn}. "
|
|
1438
|
+
f"Refer to the tool response from Turn {prev_turn} for its content."
|
|
1439
|
+
)
|