localgate 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. localgate/__init__.py +8 -0
  2. localgate/__main__.py +6 -0
  3. localgate/agent/__init__.py +8 -0
  4. localgate/agent/gitutil.py +104 -0
  5. localgate/agent/ignore.py +66 -0
  6. localgate/agent/loop.py +322 -0
  7. localgate/agent/memory.py +149 -0
  8. localgate/agent/render.py +64 -0
  9. localgate/agent/repl.py +238 -0
  10. localgate/agent/tools.py +292 -0
  11. localgate/api/__init__.py +0 -0
  12. localgate/api/chat.py +410 -0
  13. localgate/api/completions.py +82 -0
  14. localgate/api/config.py +150 -0
  15. localgate/api/conversations.py +69 -0
  16. localgate/api/deps.py +85 -0
  17. localgate/api/embeddings.py +55 -0
  18. localgate/api/export.py +92 -0
  19. localgate/api/health.py +126 -0
  20. localgate/api/keys.py +114 -0
  21. localgate/api/models.py +39 -0
  22. localgate/api/usage.py +31 -0
  23. localgate/app.py +183 -0
  24. localgate/backends/__init__.py +98 -0
  25. localgate/backends/base.py +43 -0
  26. localgate/backends/fake.py +90 -0
  27. localgate/backends/llamacpp.py +16 -0
  28. localgate/backends/ollama.py +23 -0
  29. localgate/backends/openai_compat.py +97 -0
  30. localgate/backends/vllm.py +16 -0
  31. localgate/cli.py +471 -0
  32. localgate/config.py +142 -0
  33. localgate/core/__init__.py +0 -0
  34. localgate/core/auth.py +46 -0
  35. localgate/core/cache.py +91 -0
  36. localgate/core/db_config_store.py +46 -0
  37. localgate/core/errors.py +146 -0
  38. localgate/core/logging.py +66 -0
  39. localgate/core/metrics.py +68 -0
  40. localgate/core/rate_limiter.py +66 -0
  41. localgate/core/streaming.py +41 -0
  42. localgate/core/token_counter.py +58 -0
  43. localgate/core/types.py +159 -0
  44. localgate/dashboard/__init__.py +0 -0
  45. localgate/dashboard/routes.py +29 -0
  46. localgate/dashboard/static/index.html +878 -0
  47. localgate/db/__init__.py +0 -0
  48. localgate/db/engine.py +134 -0
  49. localgate/db/migrations/__init__.py +1 -0
  50. localgate/db/migrations/alembic.ini +46 -0
  51. localgate/db/migrations/env.py +92 -0
  52. localgate/db/migrations/script.py.mako +27 -0
  53. localgate/db/migrations/versions/0001_initial_schema.py +92 -0
  54. localgate/db/migrations/versions/0002_summaries_and_accounting.py +135 -0
  55. localgate/db/models.py +133 -0
  56. localgate/db/repositories/__init__.py +0 -0
  57. localgate/db/repositories/conversations.py +114 -0
  58. localgate/db/repositories/embeddings.py +103 -0
  59. localgate/db/repositories/keys.py +82 -0
  60. localgate/db/repositories/usage.py +146 -0
  61. localgate/memory/__init__.py +0 -0
  62. localgate/memory/chunker.py +23 -0
  63. localgate/memory/context_builder.py +53 -0
  64. localgate/memory/embedder.py +7 -0
  65. localgate/memory/retriever.py +31 -0
  66. localgate/memory/summarizer.py +125 -0
  67. localgate/middleware/__init__.py +10 -0
  68. localgate/middleware/logging_middleware.py +113 -0
  69. localgate-0.7.0.dist-info/METADATA +239 -0
  70. localgate-0.7.0.dist-info/RECORD +73 -0
  71. localgate-0.7.0.dist-info/WHEEL +4 -0
  72. localgate-0.7.0.dist-info/entry_points.txt +9 -0
  73. localgate-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,64 @@
1
+ """Terminal rendering for the coding agent: colored diffs and status lines.
2
+
3
+ Kept separate from `cli.py` so the line-classification logic (`diff_lines`) is
4
+ testable without a terminal — golden-file tests assert on the returned
5
+ ``(text, style)`` pairs directly rather than parsing captured ANSI output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+
12
+ from rich.console import Console
13
+ from rich.syntax import Syntax
14
+ from rich.text import Text
15
+
16
+ _STYLE_FOR_PREFIX = (
17
+ ("+++", "dim"),
18
+ ("---", "dim"),
19
+ ("@@", "cyan"),
20
+ ("+", "green"),
21
+ ("-", "red"),
22
+ )
23
+
24
+
25
+ def diff_lines(path: str, old_content: str, new_content: str) -> list[tuple[str, str]]:
26
+ """A unified diff between ``old_content`` and ``new_content``, one (line, style) pair
27
+ per line. ``old_content`` of ``""`` renders as an all-additions diff, which is
28
+ exactly right for a brand-new file.
29
+ """
30
+ diff = difflib.unified_diff(
31
+ old_content.splitlines(keepends=True),
32
+ new_content.splitlines(keepends=True),
33
+ fromfile=path,
34
+ tofile=path,
35
+ )
36
+ lines: list[tuple[str, str]] = []
37
+ for raw in diff:
38
+ line = raw.rstrip("\n")
39
+ style = "default"
40
+ for prefix, prefix_style in _STYLE_FOR_PREFIX:
41
+ if line.startswith(prefix):
42
+ style = prefix_style
43
+ break
44
+ lines.append((line, style))
45
+ return lines
46
+
47
+
48
+ def print_diff(console: Console, path: str, old_content: str, new_content: str) -> None:
49
+ """Render a colored unified diff for a pending write, or the new file's
50
+ contents with syntax highlighting when there's nothing to diff against.
51
+ """
52
+ if not old_content:
53
+ console.print(f"[dim]--- new file {path} ---[/dim]")
54
+ console.print(Syntax(new_content, _lexer_for(path), theme="ansi_dark", line_numbers=True))
55
+ return
56
+ console.rule(f"[bold]{path}[/bold]")
57
+ for line, style in diff_lines(path, old_content, new_content):
58
+ console.print(Text(line, style=style))
59
+ console.rule()
60
+
61
+
62
+ def _lexer_for(path: str) -> str:
63
+ suffix = path.rsplit(".", 1)[-1] if "." in path else ""
64
+ return suffix or "text"
@@ -0,0 +1,238 @@
1
+ """Interactive REPL for `localgate code`, and the write-safety plumbing it shares
2
+ with the single-shot invocation: a dirty-tree gate, colored diffs before every
3
+ write, a spinner while waiting on the model, and `/undo`.
4
+
5
+ Kept out of `cli.py` because none of this is Typer-specific — it is pure asyncio
6
+ and Rich, and is exercised directly in tests without going through the CLI layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from localgate.agent import gitutil
18
+ from localgate.agent.loop import AgentSession, AgentTurnLimitExceeded
19
+ from localgate.agent.memory import AgentMemory
20
+ from localgate.agent.render import print_diff
21
+ from localgate.agent.tools import ToolCallResult, execute_tool_call
22
+
23
+ HELP_TEXT = "[dim]/exit /clear /model <name> /undo[/dim]"
24
+
25
+
26
+ class WriteGate:
27
+ """Confirms writes with a diff, gates once on a dirty tree, and tracks what
28
+ changed so `/undo` and `--auto-commit` have something to act on.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ console: Console,
34
+ root: Path,
35
+ *,
36
+ auto_approve: bool = False,
37
+ force: bool = False,
38
+ auto_commit: bool = False,
39
+ ) -> None:
40
+ self.console = console
41
+ self.root = root
42
+ self.auto_approve = auto_approve
43
+ self.force = force
44
+ self.auto_commit = auto_commit
45
+ self.is_repo = gitutil.is_repo(root)
46
+ self._dirty_checked = False
47
+ self.last_written_path: str | None = None
48
+ self.writes_this_turn: list[str] = []
49
+
50
+ def _dirty_tree_ok(self) -> bool:
51
+ """Warn about a dirty tree once per session; the user opts back in or bails."""
52
+ if self._dirty_checked or not self.is_repo:
53
+ self._dirty_checked = True
54
+ return True
55
+ self._dirty_checked = True
56
+ if self.force or not gitutil.is_dirty(self.root):
57
+ return True
58
+ self.console.print(
59
+ "[yellow]! uncommitted changes exist in this project — agent writes may be "
60
+ "hard to distinguish from your own edits.[/yellow]"
61
+ )
62
+ return typer.confirm("Continue anyway?", default=False)
63
+
64
+ def confirm_write(self, path: str, content: str) -> bool:
65
+ if not self._dirty_tree_ok():
66
+ return False
67
+
68
+ target = self.root / path
69
+ old_content = ""
70
+ if target.is_file():
71
+ try:
72
+ old_content = target.read_text(encoding="utf-8", errors="replace")
73
+ except OSError:
74
+ old_content = ""
75
+ print_diff(self.console, path, old_content, content)
76
+
77
+ if self.auto_approve:
78
+ return True
79
+ return typer.confirm(f"Write {path}?", default=False)
80
+
81
+ def tracking_executor(
82
+ self, root: Path, tool_call_id: str, name: str, arguments: dict[str, Any]
83
+ ) -> ToolCallResult:
84
+ """Wraps the default tool executor to record successful writes."""
85
+ result = execute_tool_call(root, tool_call_id, name, arguments)
86
+ if name == "write_file" and not result.is_error:
87
+ path = arguments.get("path", "?")
88
+ self.last_written_path = path
89
+ self.writes_this_turn.append(path)
90
+ return result
91
+
92
+ def after_turn(self, summary: str) -> None:
93
+ """Auto-commit whatever was written this turn, if that's enabled."""
94
+ if self.auto_commit and self.is_repo and self.writes_this_turn:
95
+ message = f"{gitutil.AGENT_COMMIT_PREFIX} {summary[:60]}"
96
+ gitutil.commit_all(self.root, message)
97
+ self.writes_this_turn = []
98
+
99
+ def undo(self) -> str:
100
+ if not self.is_repo:
101
+ return "Not a git repository — nothing to undo automatically."
102
+ if self.auto_commit:
103
+ message = gitutil.last_commit_message(self.root)
104
+ if message is None or not message.startswith(gitutil.AGENT_COMMIT_PREFIX):
105
+ return "The last commit wasn't made by the agent — refusing to reset it."
106
+ gitutil.reset_hard_last(self.root)
107
+ return f"Reset the last agent commit: {message}"
108
+ if self.last_written_path is None:
109
+ return "Nothing written yet this session to undo."
110
+ outcome = gitutil.undo_file(self.root, self.last_written_path)
111
+ self.last_written_path = None
112
+ return outcome
113
+
114
+
115
+ async def run_turn(
116
+ console: Console,
117
+ session: AgentSession,
118
+ gate: WriteGate,
119
+ user_input: str,
120
+ memory: AgentMemory | None = None,
121
+ ) -> str:
122
+ """Run one turn with a spinner that clears the moment the model produces
123
+ anything — streamed text or a tool-call event — auto-commit afterward, and
124
+ record the turn into conversation history/memory if a session is attached.
125
+ """
126
+ status = console.status("[dim]thinking...[/dim]", spinner="dots")
127
+ status.start()
128
+ stopped = False
129
+
130
+ def stop() -> None:
131
+ nonlocal stopped
132
+ if not stopped:
133
+ status.stop()
134
+ stopped = True
135
+
136
+ def on_token(text: str) -> None:
137
+ stop()
138
+ console.print(text, end="")
139
+
140
+ def on_event(line: str) -> None:
141
+ stop()
142
+ console.print(f"[cyan] {line}[/cyan]")
143
+
144
+ session.on_token = on_token
145
+ session.on_event = on_event
146
+ if memory is not None:
147
+ session.augment = memory.augment
148
+ try:
149
+ result = await session.send(user_input)
150
+ finally:
151
+ stop()
152
+
153
+ gate.after_turn(user_input)
154
+ console.print()
155
+ if memory is not None:
156
+ await memory.record_turn(user_input, result)
157
+ return result
158
+
159
+
160
+ async def run_repl(
161
+ backend: Any,
162
+ model: str,
163
+ root: Path,
164
+ *,
165
+ auto_approve: bool = False,
166
+ force: bool = False,
167
+ auto_commit: bool = False,
168
+ memory: AgentMemory | None = None,
169
+ ) -> None:
170
+ """A persistent chat session in `root`, until `/exit` or EOF (Ctrl+D)."""
171
+ console = Console()
172
+ gate = WriteGate(console, root, auto_approve=auto_approve, force=force, auto_commit=auto_commit)
173
+ session = AgentSession(
174
+ backend, model, root, confirm_write=gate.confirm_write, tool_executor=gate.tracking_executor
175
+ )
176
+
177
+ console.print(f"[bold]localgate code[/bold] — {root}")
178
+ console.print(HELP_TEXT)
179
+ console.print()
180
+
181
+ while True:
182
+ try:
183
+ line = console.input("[bold green]> [/bold green]")
184
+ except EOFError:
185
+ console.print()
186
+ break
187
+ line = line.strip()
188
+ if not line:
189
+ continue
190
+
191
+ if line == "/exit":
192
+ break
193
+ if line == "/clear":
194
+ session.reset()
195
+ gate.writes_this_turn = []
196
+ console.print("[dim]conversation cleared[/dim]")
197
+ continue
198
+ if line.startswith("/model"):
199
+ parts = line.split(maxsplit=1)
200
+ if len(parts) == 2:
201
+ session.model = parts[1].strip()
202
+ console.print(f"[dim]model set to {session.model}[/dim]")
203
+ else:
204
+ console.print(f"[dim]current model: {session.model}[/dim]")
205
+ continue
206
+ if line == "/undo":
207
+ console.print(f"[yellow]{gate.undo()}[/yellow]")
208
+ continue
209
+ if line.startswith("/"):
210
+ console.print(f"[dim]unknown command {line!r} — {HELP_TEXT}[/dim]")
211
+ continue
212
+
213
+ try:
214
+ await run_turn(console, session, gate, line, memory=memory)
215
+ except AgentTurnLimitExceeded as exc:
216
+ console.print(f"[red]{exc}[/red]")
217
+ except KeyboardInterrupt:
218
+ console.print("\n[yellow]cancelled — session still open[/yellow]")
219
+
220
+
221
+ async def run_single_shot(
222
+ backend: Any,
223
+ model: str,
224
+ root: Path,
225
+ task: str,
226
+ *,
227
+ auto_approve: bool = False,
228
+ force: bool = False,
229
+ auto_commit: bool = False,
230
+ memory: AgentMemory | None = None,
231
+ ) -> str:
232
+ """One task, one turn, with the same diff/spinner/streaming UI as the REPL."""
233
+ console = Console()
234
+ gate = WriteGate(console, root, auto_approve=auto_approve, force=force, auto_commit=auto_commit)
235
+ session = AgentSession(
236
+ backend, model, root, confirm_write=gate.confirm_write, tool_executor=gate.tracking_executor
237
+ )
238
+ return await run_turn(console, session, gate, task, memory=memory)
@@ -0,0 +1,292 @@
1
+ """Tools available to the coding agent: filesystem access, a project-wide search,
2
+ and read-only git awareness.
3
+
4
+ Every tool is confined to a single project root. The model only ever sees paths
5
+ relative to that root, and every path is resolved and checked against it before
6
+ any filesystem operation runs — a `../../etc/passwd` or an absolute path from the
7
+ model is rejected rather than followed. Paths matching `.gitignore` or
8
+ `.localgateignore` are invisible to every tool too, layered on top of that same
9
+ check, so secrets and generated directories stay out of the model's reach even if
10
+ it asks by name. This is the one piece of this module that must not have a bug:
11
+ it's the entire difference between "the agent edits your project" and "the agent
12
+ edits your filesystem."
13
+
14
+ There is deliberately no `run_command`/shell-execution tool. That's the single
15
+ riskiest capability a coding agent can have, and CODING_AGENT_PLAN.md calls for
16
+ adding it — if ever — behind mandatory per-call confirmation and a real sandbox,
17
+ not as a peer to these four read/write tools.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import re
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from localgate.agent import gitutil
29
+ from localgate.agent.ignore import is_ignored, load_patterns
30
+
31
+ #: OpenAI-style tool definitions, passed as `tools` on the chat completions request.
32
+ TOOL_SCHEMAS: list[dict[str, Any]] = [
33
+ {
34
+ "type": "function",
35
+ "function": {
36
+ "name": "read_file",
37
+ "description": "Read the full contents of a text file in the project.",
38
+ "parameters": {
39
+ "type": "object",
40
+ "properties": {
41
+ "path": {
42
+ "type": "string",
43
+ "description": "Path relative to the project root, e.g. 'src/app.py'.",
44
+ }
45
+ },
46
+ "required": ["path"],
47
+ },
48
+ },
49
+ },
50
+ {
51
+ "type": "function",
52
+ "function": {
53
+ "name": "write_file",
54
+ "description": (
55
+ "Create or overwrite a text file in the project with the given content. "
56
+ "Always read the file first if it already exists, so the write is an "
57
+ "intentional full replacement rather than a guess."
58
+ ),
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {
62
+ "path": {
63
+ "type": "string",
64
+ "description": "Path relative to the project root.",
65
+ },
66
+ "content": {
67
+ "type": "string",
68
+ "description": "The complete new contents of the file.",
69
+ },
70
+ },
71
+ "required": ["path", "content"],
72
+ },
73
+ },
74
+ },
75
+ {
76
+ "type": "function",
77
+ "function": {
78
+ "name": "list_directory",
79
+ "description": "List files and subdirectories at a path in the project.",
80
+ "parameters": {
81
+ "type": "object",
82
+ "properties": {
83
+ "path": {
84
+ "type": "string",
85
+ "description": "Path relative to the project root. Defaults to '.'.",
86
+ }
87
+ },
88
+ "required": [],
89
+ },
90
+ },
91
+ },
92
+ {
93
+ "type": "function",
94
+ "function": {
95
+ "name": "search_files",
96
+ "description": (
97
+ "Search file contents in the project for a regular expression, like grep. "
98
+ "Returns matching lines as 'path:line: text'. Use this to find relevant code "
99
+ "without already knowing the exact filename."
100
+ ),
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "pattern": {"type": "string", "description": "A regular expression."},
105
+ "path": {
106
+ "type": "string",
107
+ "description": (
108
+ "Directory to search under, relative to the project root. "
109
+ "Defaults to '.'."
110
+ ),
111
+ },
112
+ },
113
+ "required": ["pattern"],
114
+ },
115
+ },
116
+ },
117
+ {
118
+ "type": "function",
119
+ "function": {
120
+ "name": "git_status",
121
+ "description": "Show which files are modified, staged, or untracked — read-only.",
122
+ "parameters": {"type": "object", "properties": {}},
123
+ },
124
+ },
125
+ {
126
+ "type": "function",
127
+ "function": {
128
+ "name": "git_diff",
129
+ "description": "Show unstaged changes, optionally limited to one file — read-only.",
130
+ "parameters": {
131
+ "type": "object",
132
+ "properties": {
133
+ "path": {
134
+ "type": "string",
135
+ "description": "Limit the diff to this file, relative to the project root.",
136
+ }
137
+ },
138
+ "required": [],
139
+ },
140
+ },
141
+ },
142
+ ]
143
+
144
+ TOOL_NAMES = frozenset(schema["function"]["name"] for schema in TOOL_SCHEMAS)
145
+
146
+
147
+ class PathEscapeError(ValueError):
148
+ """Raised when a tool argument would resolve outside the project root."""
149
+
150
+
151
+ class IgnoredPathError(ValueError):
152
+ """Raised when a tool argument matches .gitignore/.localgateignore."""
153
+
154
+
155
+ def resolve_within(root: Path, relative: str) -> Path:
156
+ """Resolve ``relative`` against ``root`` and refuse anything that escapes it.
157
+
158
+ Handles both `../` traversal and absolute paths (which `Path.__truediv__`
159
+ would otherwise happily accept, silently discarding ``root``).
160
+ """
161
+ root = root.resolve()
162
+ candidate = (root / relative).resolve()
163
+ with contextlib.suppress(ValueError):
164
+ candidate.relative_to(root)
165
+ return candidate
166
+ raise PathEscapeError(f"{relative!r} resolves outside the project root ({root})")
167
+
168
+
169
+ def ensure_visible(root: Path, candidate: Path) -> None:
170
+ """Refuse a path excluded by `.gitignore`/`.localgateignore` (or inside `.git`)."""
171
+ if is_ignored(root, candidate, load_patterns(root)):
172
+ relative = candidate.relative_to(root.resolve())
173
+ raise IgnoredPathError(f"{relative} is excluded by .gitignore/.localgateignore")
174
+
175
+
176
+ def read_file(root: Path, path: str) -> str:
177
+ target = resolve_within(root, path)
178
+ ensure_visible(root, target)
179
+ if not target.is_file():
180
+ raise FileNotFoundError(f"No such file: {path}")
181
+ return target.read_text(encoding="utf-8", errors="replace")
182
+
183
+
184
+ def write_file(root: Path, path: str, content: str) -> None:
185
+ target = resolve_within(root, path)
186
+ ensure_visible(root, target)
187
+ target.parent.mkdir(parents=True, exist_ok=True)
188
+ target.write_text(content, encoding="utf-8")
189
+
190
+
191
+ def list_directory(root: Path, path: str = ".") -> list[str]:
192
+ target = resolve_within(root, path)
193
+ ensure_visible(root, target)
194
+ if not target.is_dir():
195
+ raise NotADirectoryError(f"Not a directory: {path}")
196
+ root = root.resolve()
197
+ patterns = load_patterns(root)
198
+ entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name))
199
+ return [
200
+ f"{entry.name}/" if entry.is_dir() else entry.name
201
+ for entry in entries
202
+ if not is_ignored(root, entry, patterns)
203
+ ]
204
+
205
+
206
+ def search_files(root: Path, pattern: str, path: str = ".", max_results: int = 200) -> list[str]:
207
+ """Grep-like content search under ``path``, skipping ignored and binary files."""
208
+ start = resolve_within(root, path)
209
+ ensure_visible(root, start)
210
+ if not start.is_dir():
211
+ raise NotADirectoryError(f"Not a directory: {path}")
212
+
213
+ root = root.resolve()
214
+ patterns = load_patterns(root)
215
+ regex = re.compile(pattern)
216
+ results: list[str] = []
217
+
218
+ for file_path in sorted(start.rglob("*")):
219
+ if not file_path.is_file() or is_ignored(root, file_path, patterns):
220
+ continue
221
+ try:
222
+ raw = file_path.read_bytes()
223
+ except OSError:
224
+ continue
225
+ if b"\0" in raw:
226
+ continue # a NUL byte is the cheap, standard heuristic for "this is binary"
227
+ try:
228
+ text = raw.decode("utf-8")
229
+ except UnicodeDecodeError:
230
+ continue
231
+ relative = file_path.relative_to(root).as_posix()
232
+ for lineno, line in enumerate(text.splitlines(), start=1):
233
+ if regex.search(line):
234
+ results.append(f"{relative}:{lineno}: {line.strip()}")
235
+ if len(results) >= max_results:
236
+ return results
237
+ return results
238
+
239
+
240
+ def git_status(root: Path) -> str:
241
+ if not gitutil.is_repo(root):
242
+ return "Not a git repository."
243
+ return gitutil.status(root) or "Working tree clean."
244
+
245
+
246
+ def git_diff(root: Path, path: str | None = None) -> str:
247
+ if not gitutil.is_repo(root):
248
+ return "Not a git repository."
249
+ if path is not None:
250
+ ensure_visible(root, resolve_within(root, path))
251
+ return gitutil.diff(root, path) or "No changes."
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class ToolCallResult:
256
+ """The outcome of executing one tool call, ready to feed back to the model."""
257
+
258
+ tool_call_id: str
259
+ name: str
260
+ content: str
261
+ is_error: bool = False
262
+
263
+
264
+ def execute_tool_call(
265
+ root: Path, tool_call_id: str, name: str, arguments: dict[str, Any]
266
+ ) -> ToolCallResult:
267
+ """Run one tool call and turn any failure into a result the model can react to.
268
+
269
+ A tool that raises would kill the whole agent loop over something as mundane
270
+ as a typo'd filename; the model should see "No such file: foo.py" as a tool
271
+ result and try again, not crash the CLI.
272
+ """
273
+ try:
274
+ if name == "read_file":
275
+ content = read_file(root, arguments["path"])
276
+ elif name == "write_file":
277
+ write_file(root, arguments["path"], arguments["content"])
278
+ content = f"Wrote {len(arguments['content'])} bytes to {arguments['path']}"
279
+ elif name == "list_directory":
280
+ content = "\n".join(list_directory(root, arguments.get("path", ".")))
281
+ elif name == "search_files":
282
+ matches = search_files(root, arguments["pattern"], arguments.get("path", "."))
283
+ content = "\n".join(matches) if matches else "No matches."
284
+ elif name == "git_status":
285
+ content = git_status(root)
286
+ elif name == "git_diff":
287
+ content = git_diff(root, arguments.get("path"))
288
+ else:
289
+ return ToolCallResult(tool_call_id, name, f"Unknown tool: {name}", is_error=True)
290
+ except Exception as exc: # noqa: BLE001 — any failure becomes a tool-result the model reads
291
+ return ToolCallResult(tool_call_id, name, f"{type(exc).__name__}: {exc}", is_error=True)
292
+ return ToolCallResult(tool_call_id, name, content)
File without changes