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.
- localgate/__init__.py +8 -0
- localgate/__main__.py +6 -0
- localgate/agent/__init__.py +8 -0
- localgate/agent/gitutil.py +104 -0
- localgate/agent/ignore.py +66 -0
- localgate/agent/loop.py +322 -0
- localgate/agent/memory.py +149 -0
- localgate/agent/render.py +64 -0
- localgate/agent/repl.py +238 -0
- localgate/agent/tools.py +292 -0
- localgate/api/__init__.py +0 -0
- localgate/api/chat.py +410 -0
- localgate/api/completions.py +82 -0
- localgate/api/config.py +150 -0
- localgate/api/conversations.py +69 -0
- localgate/api/deps.py +85 -0
- localgate/api/embeddings.py +55 -0
- localgate/api/export.py +92 -0
- localgate/api/health.py +126 -0
- localgate/api/keys.py +114 -0
- localgate/api/models.py +39 -0
- localgate/api/usage.py +31 -0
- localgate/app.py +183 -0
- localgate/backends/__init__.py +98 -0
- localgate/backends/base.py +43 -0
- localgate/backends/fake.py +90 -0
- localgate/backends/llamacpp.py +16 -0
- localgate/backends/ollama.py +23 -0
- localgate/backends/openai_compat.py +97 -0
- localgate/backends/vllm.py +16 -0
- localgate/cli.py +471 -0
- localgate/config.py +142 -0
- localgate/core/__init__.py +0 -0
- localgate/core/auth.py +46 -0
- localgate/core/cache.py +91 -0
- localgate/core/db_config_store.py +46 -0
- localgate/core/errors.py +146 -0
- localgate/core/logging.py +66 -0
- localgate/core/metrics.py +68 -0
- localgate/core/rate_limiter.py +66 -0
- localgate/core/streaming.py +41 -0
- localgate/core/token_counter.py +58 -0
- localgate/core/types.py +159 -0
- localgate/dashboard/__init__.py +0 -0
- localgate/dashboard/routes.py +29 -0
- localgate/dashboard/static/index.html +878 -0
- localgate/db/__init__.py +0 -0
- localgate/db/engine.py +134 -0
- localgate/db/migrations/__init__.py +1 -0
- localgate/db/migrations/alembic.ini +46 -0
- localgate/db/migrations/env.py +92 -0
- localgate/db/migrations/script.py.mako +27 -0
- localgate/db/migrations/versions/0001_initial_schema.py +92 -0
- localgate/db/migrations/versions/0002_summaries_and_accounting.py +135 -0
- localgate/db/models.py +133 -0
- localgate/db/repositories/__init__.py +0 -0
- localgate/db/repositories/conversations.py +114 -0
- localgate/db/repositories/embeddings.py +103 -0
- localgate/db/repositories/keys.py +82 -0
- localgate/db/repositories/usage.py +146 -0
- localgate/memory/__init__.py +0 -0
- localgate/memory/chunker.py +23 -0
- localgate/memory/context_builder.py +53 -0
- localgate/memory/embedder.py +7 -0
- localgate/memory/retriever.py +31 -0
- localgate/memory/summarizer.py +125 -0
- localgate/middleware/__init__.py +10 -0
- localgate/middleware/logging_middleware.py +113 -0
- localgate-0.7.0.dist-info/METADATA +239 -0
- localgate-0.7.0.dist-info/RECORD +73 -0
- localgate-0.7.0.dist-info/WHEEL +4 -0
- localgate-0.7.0.dist-info/entry_points.txt +9 -0
- 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"
|
localgate/agent/repl.py
ADDED
|
@@ -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)
|
localgate/agent/tools.py
ADDED
|
@@ -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
|