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.
@@ -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
+ )