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
localgate/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """localgate — a local-first API gateway for open-source LLMs.
2
+
3
+ Adds authentication, RAG-powered memory extension, database connectors,
4
+ and token accounting on top of any local inference backend
5
+ (Ollama, llama.cpp, vLLM).
6
+ """
7
+
8
+ __version__ = "0.7.0"
localgate/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m localgate`."""
2
+
3
+ from localgate.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,8 @@
1
+ """``localgate code`` — a minimal agentic coding loop over a chat-completions backend.
2
+
3
+ This is deliberately small: three filesystem tools (`agent.tools`) and a loop that
4
+ feeds tool calls back to the model until it answers in plain text (`agent.loop`).
5
+ See CODING_AGENT_PLAN.md for what this is a first step toward.
6
+ """
7
+
8
+ from __future__ import annotations
@@ -0,0 +1,104 @@
1
+ """Minimal git plumbing for the coding agent's safety net.
2
+
3
+ Shells out to the ``git`` binary rather than adding a dependency like GitPython —
4
+ these are a handful of read-only or trivially-reversible commands, not a reason to
5
+ carry a whole library. Every write here (`commit_all`, `checkout_file`,
6
+ `reset_hard_last`) exists to make an agent's mistake a five-second `git` command
7
+ away from undone, per CODING_AGENT_PLAN.md's git-aware safety net.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ #: Commit messages the agent makes are prefixed with this, so `/undo` can refuse
16
+ #: to `reset --hard` a commit a human made themselves.
17
+ AGENT_COMMIT_PREFIX = "localgate-agent:"
18
+
19
+
20
+ class GitError(RuntimeError):
21
+ """A git command exited non-zero, or git isn't installed."""
22
+
23
+
24
+ def _run(root: Path, *args: str) -> str:
25
+ try:
26
+ result = subprocess.run(
27
+ ["git", *args], cwd=root, capture_output=True, text=True, check=False
28
+ )
29
+ except FileNotFoundError as exc:
30
+ raise GitError("git is not installed or not on PATH") from exc
31
+ if result.returncode != 0:
32
+ message = result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed"
33
+ raise GitError(message)
34
+ return result.stdout
35
+
36
+
37
+ def is_repo(root: Path) -> bool:
38
+ try:
39
+ return _run(root, "rev-parse", "--is-inside-work-tree").strip() == "true"
40
+ except GitError:
41
+ return False
42
+
43
+
44
+ def is_dirty(root: Path) -> bool:
45
+ return bool(_run(root, "status", "--porcelain").strip())
46
+
47
+
48
+ def status(root: Path) -> str:
49
+ return _run(root, "status", "--porcelain")
50
+
51
+
52
+ def diff(root: Path, path: str | None = None) -> str:
53
+ args = ["diff"] if path is None else ["diff", "--", path]
54
+ return _run(root, *args)
55
+
56
+
57
+ def last_commit_message(root: Path) -> str | None:
58
+ try:
59
+ return _run(root, "log", "-1", "--format=%s").strip()
60
+ except GitError:
61
+ return None # no commits yet
62
+
63
+
64
+ def commit_all(root: Path, message: str) -> bool:
65
+ """Stage and commit everything. Returns False if there was nothing to commit."""
66
+ _run(root, "add", "-A")
67
+ try:
68
+ _run(root, "commit", "-m", message)
69
+ except GitError as exc:
70
+ if "nothing to commit" in str(exc):
71
+ return False
72
+ raise
73
+ return True
74
+
75
+
76
+ def checkout_file(root: Path, path: str) -> None:
77
+ """Restore ``path`` to its state in the last commit, discarding local edits."""
78
+ _run(root, "checkout", "--", path)
79
+
80
+
81
+ def reset_hard_last(root: Path) -> None:
82
+ """Drop the last commit and its changes entirely. Irreversible past this point."""
83
+ _run(root, "reset", "--hard", "HEAD~1")
84
+
85
+
86
+ def file_is_untracked(root: Path, path: str) -> bool:
87
+ """True if ``path`` has never been committed (a plain `checkout --` can't undo it)."""
88
+ porcelain = _run(root, "status", "--porcelain", "--", path)
89
+ return porcelain.startswith("??")
90
+
91
+
92
+ def undo_file(root: Path, path: str) -> str:
93
+ """Best-effort undo of the most recent write to ``path``.
94
+
95
+ A brand-new file has nothing to check out back to, so undoing it means
96
+ deleting it; anything else reverts to the last commit.
97
+ """
98
+ if file_is_untracked(root, path):
99
+ target = root / path
100
+ if target.exists():
101
+ target.unlink()
102
+ return f"Deleted newly created {path} (it was never committed)."
103
+ checkout_file(root, path)
104
+ return f"Reverted {path} to its last committed version."
@@ -0,0 +1,66 @@
1
+ """A minimal `.gitignore`-style pattern matcher for `.gitignore` and
2
+ `.localgateignore`.
3
+
4
+ This is a deliberate subset, not a full implementation: `*`, `**`, `?`, a leading
5
+ `/` to anchor at the root, and a trailing `/` for directory-only patterns are all
6
+ supported; negation (`!pattern`) is not — a negation line is simply skipped, which
7
+ means it's ignored, and everything it would have un-ignored stays covered by
8
+ whatever pattern matched it. That is the safe direction to be wrong in for a tool
9
+ whose whole job is keeping secrets out of the model's hands: under-ignoring would
10
+ leak a file, over-ignoring only costs the model a read it didn't get to make.
11
+
12
+ The `.git` directory is always excluded, regardless of what's in either ignore
13
+ file — there is no legitimate reason for the agent to read `.git/config` or
14
+ `.git/objects`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from pathlib import Path
21
+
22
+ _ALWAYS_IGNORED_DIRS = frozenset({".git"})
23
+
24
+
25
+ def _pattern_to_regex(pattern: str) -> re.Pattern[str]:
26
+ # A trailing "/" restricts a real gitignore pattern to directories, but either
27
+ # way a match on a directory also covers everything under it — so the two
28
+ # cases only differ in a distinction (file vs. directory) this matcher
29
+ # doesn't track. Both are treated the same: match the entry itself, or
30
+ # anything nested below it.
31
+ body = pattern.rstrip("/")
32
+ anchored = body.startswith("/")
33
+ body = body.lstrip("/")
34
+
35
+ # Escape everything, then re-expand the glob metacharacters we support.
36
+ # A NUL placeholder for "**" survives re.escape and single-"*" expansion
37
+ # untouched, since NUL never appears in a real pattern.
38
+ escaped = re.escape(body).replace(r"\*\*", "\0").replace(r"\*", "[^/]*")
39
+ escaped = escaped.replace(r"\?", "[^/]").replace("\0", ".*")
40
+
41
+ prefix = "^" if anchored else "(^|.*/)"
42
+ return re.compile(prefix + escaped + "(/.*)?$")
43
+
44
+
45
+ def load_patterns(root: Path) -> list[re.Pattern[str]]:
46
+ """Read `.gitignore` and `.localgateignore` from ``root``, if present."""
47
+ patterns: list[re.Pattern[str]] = []
48
+ for filename in (".gitignore", ".localgateignore"):
49
+ ignore_file = root / filename
50
+ if not ignore_file.is_file():
51
+ continue
52
+ for raw_line in ignore_file.read_text(encoding="utf-8", errors="replace").splitlines():
53
+ line = raw_line.strip()
54
+ if not line or line.startswith("#") or line.startswith("!"):
55
+ continue
56
+ patterns.append(_pattern_to_regex(line))
57
+ return patterns
58
+
59
+
60
+ def is_ignored(root: Path, path: Path, patterns: list[re.Pattern[str]]) -> bool:
61
+ """Whether ``path`` (absolute, inside ``root``) is excluded from the agent's view."""
62
+ relative = path.relative_to(root)
63
+ if _ALWAYS_IGNORED_DIRS.intersection(relative.parts):
64
+ return True
65
+ rel_posix = relative.as_posix()
66
+ return any(pattern.match(rel_posix) for pattern in patterns)
@@ -0,0 +1,322 @@
1
+ """The agent's turn loop: ask the model, run any tool calls, feed results back, repeat.
2
+
3
+ This talks to an :class:`~localgate.backends.base.InferenceBackend` directly rather
4
+ than over HTTP — the same pattern ``cli.py`` already uses for ``localgate health``.
5
+ That sidesteps API-key management entirely for what is, for now, a local dev tool
6
+ running on the same machine as the backend.
7
+
8
+ :class:`AgentSession` holds the conversation and runs it turn by turn, so the REPL
9
+ can keep one session alive across many user inputs. :func:`run_agent` is a thin
10
+ convenience wrapper around a single-turn session, used by the single-shot
11
+ ``localgate code "task"`` invocation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import uuid
19
+ from collections.abc import Awaitable, Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from localgate.agent.tools import TOOL_SCHEMAS, ToolCallResult, execute_tool_call
24
+ from localgate.backends.base import InferenceBackend
25
+
26
+ SYSTEM_PROMPT = (
27
+ "You are a coding assistant working directly in the user's project directory. "
28
+ "Use the available tools to inspect and modify the project as needed to complete "
29
+ "the user's task. Prefer reading a file before overwriting it. When you are done, "
30
+ "reply with plain text summarizing what you did — do not call a tool in the same "
31
+ "turn as your final summary. If you cannot use structured tool calls, respond with "
32
+ 'a single JSON object of the form {"name": "<tool>", "arguments": {...}} and '
33
+ "nothing else — no prose, no markdown fence."
34
+ )
35
+
36
+ #: Some models (observed live with qwen2.5-coder via Ollama's OpenAI-compat shim)
37
+ #: never populate `tool_calls` at all — they print the tool call as plain-text JSON
38
+ #: in `content` instead, despite advertising tool-calling support. `_FENCE_RE` and
39
+ #: `_TAG_RE` strip the wrappers some other models use for the same failure mode: a
40
+ #: markdown code fence, or some fine-tune-specific XML tag. `_TAG_RE` matches any
41
+ #: single wrapping tag by name (`<tool_call>`, `<tool_request>`, ...) rather than
42
+ #: one hardcoded name — the same model was observed emitting both across
43
+ #: otherwise-identical live requests, so a fixed tag name isn't reliable.
44
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*\n?(.*?)\n?```$", re.DOTALL)
45
+ _TAG_RE = re.compile(r"^<(\w+)>\s*(.*?)\s*</\1>$", re.DOTALL)
46
+
47
+
48
+ def _strip_wrapper(text: str) -> str:
49
+ """Peel off one layer of fence and/or tag wrapping, in either order/nesting."""
50
+ text = text.strip()
51
+ for _ in range(2): # a fence-around-a-tag or tag-around-a-fence is one pass each
52
+ fence_match = _FENCE_RE.match(text)
53
+ if fence_match:
54
+ text = fence_match.group(1).strip()
55
+ continue
56
+ tag_match = _TAG_RE.match(text)
57
+ if tag_match:
58
+ text = tag_match.group(2).strip()
59
+ continue
60
+ break
61
+ return text
62
+
63
+
64
+ def _as_synthetic_tool_call(content: str, known_names: frozenset[str]) -> dict[str, Any] | None:
65
+ """If ``content`` is *only* a disguised tool call, return it in the same shape
66
+ a real ``tool_calls`` entry has. Otherwise return ``None`` — a final answer
67
+ that happens to contain JSON (e.g. showing the user a config file) must not
68
+ be misread as an action the model didn't actually request.
69
+
70
+ The whole-string parse is itself the main guard: JSON embedded partway
71
+ through prose fails ``json.loads`` on the full string and falls through here
72
+ to a real answer, rather than requiring separate prose-detection logic.
73
+ """
74
+ cleaned = _strip_wrapper(content)
75
+ try:
76
+ parsed = json.loads(cleaned)
77
+ except json.JSONDecodeError:
78
+ return None
79
+ if not isinstance(parsed, dict):
80
+ return None
81
+
82
+ name = parsed.get("name")
83
+ arguments: Any = parsed.get("arguments")
84
+ if not isinstance(name, str):
85
+ function = parsed.get("function")
86
+ if isinstance(function, dict):
87
+ name = function.get("name")
88
+ arguments = function.get("arguments")
89
+ if not isinstance(name, str) or name not in known_names:
90
+ return None
91
+
92
+ if isinstance(arguments, dict):
93
+ arguments_json = json.dumps(arguments)
94
+ elif isinstance(arguments, str):
95
+ try:
96
+ if not isinstance(json.loads(arguments), dict):
97
+ return None
98
+ except json.JSONDecodeError:
99
+ return None
100
+ arguments_json = arguments
101
+ else:
102
+ return None
103
+
104
+ return {
105
+ "id": f"synthetic-{uuid.uuid4().hex[:8]}",
106
+ "type": "function",
107
+ "function": {"name": name, "arguments": arguments_json},
108
+ }
109
+
110
+
111
+ class AgentTurnLimitExceeded(RuntimeError):
112
+ """Raised when the model keeps calling tools without ever finishing a turn."""
113
+
114
+
115
+ #: Called before a write_file call actually runs: (path, new_content) -> proceed?
116
+ ConfirmWrite = Callable[[str, str], bool]
117
+ #: Called with a short human-readable line as each tool call happens.
118
+ OnEvent = Callable[[str], None]
119
+ #: Called with each streamed text fragment as the model produces it.
120
+ OnToken = Callable[[str], None]
121
+ #: Executes one tool call. Same shape as `tools.execute_tool_call`; Phase 3's
122
+ #: expanded tool set plugs in here without AgentSession needing to change.
123
+ ToolExecutor = Callable[[Path, str, str, dict[str, Any]], ToolCallResult]
124
+ #: Rewrites the outgoing message list for one payload — e.g. injecting recalled
125
+ #: memory — without mutating the session's own history. Called fresh before every
126
+ #: backend call, same request-scoped augmentation `chat.py` does per HTTP call.
127
+ Augment = Callable[[list[dict[str, Any]]], Awaitable[list[dict[str, Any]]]]
128
+
129
+
130
+ def _parse_arguments(raw: str) -> dict[str, Any]:
131
+ try:
132
+ parsed = json.loads(raw) if raw else {}
133
+ except json.JSONDecodeError:
134
+ return {}
135
+ return parsed if isinstance(parsed, dict) else {}
136
+
137
+
138
+ async def _stream_completion(
139
+ backend: InferenceBackend, payload: dict[str, Any], on_token: OnToken
140
+ ) -> dict[str, Any]:
141
+ """Consume a streamed chat completion and reassemble it into one message.
142
+
143
+ OpenAI-shaped tool-call deltas arrive fragment by fragment, keyed by index —
144
+ the id and function name are usually whole in the first fragment, but
145
+ `arguments` is typically streamed character by character and must be
146
+ concatenated, not replaced.
147
+ """
148
+ content_parts: list[str] = []
149
+ tool_calls: dict[int, dict[str, Any]] = {}
150
+
151
+ async for chunk in backend.chat_stream(payload):
152
+ choices = chunk.get("choices") or []
153
+ if not choices:
154
+ continue
155
+ delta = choices[0].get("delta") or {}
156
+
157
+ text = delta.get("content")
158
+ if text:
159
+ content_parts.append(text)
160
+ on_token(text)
161
+
162
+ for tc_delta in delta.get("tool_calls") or []:
163
+ index = tc_delta.get("index", 0)
164
+ entry = tool_calls.setdefault(
165
+ index, {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
166
+ )
167
+ if tc_delta.get("id"):
168
+ entry["id"] = tc_delta["id"]
169
+ fn_delta = tc_delta.get("function") or {}
170
+ if fn_delta.get("name"):
171
+ entry["function"]["name"] += fn_delta["name"]
172
+ if fn_delta.get("arguments"):
173
+ entry["function"]["arguments"] += fn_delta["arguments"]
174
+
175
+ content = "".join(content_parts)
176
+ message: dict[str, Any] = {"role": "assistant", "content": content or None}
177
+ if tool_calls:
178
+ message["tool_calls"] = [tool_calls[i] for i in sorted(tool_calls)]
179
+ return message
180
+
181
+
182
+ class AgentSession:
183
+ """One conversation with the model, across as many turns as the caller sends.
184
+
185
+ A fresh session starts with just the system prompt; each call to :meth:`send`
186
+ appends a user turn, runs the tool-call loop to completion, and returns the
187
+ model's final text. History accumulates in ``self.messages`` until :meth:`reset`.
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ backend: InferenceBackend,
193
+ model: str,
194
+ root: Path,
195
+ *,
196
+ confirm_write: ConfirmWrite | None = None,
197
+ on_event: OnEvent | None = None,
198
+ on_token: OnToken | None = None,
199
+ max_turns: int = 20,
200
+ tool_schemas: list[dict[str, Any]] | None = None,
201
+ tool_executor: ToolExecutor | None = None,
202
+ augment: Augment | None = None,
203
+ ) -> None:
204
+ self.backend = backend
205
+ self.model = model
206
+ self.root = root
207
+ self.confirm_write = confirm_write
208
+ self.on_event = on_event
209
+ self.on_token = on_token
210
+ self.max_turns = max_turns
211
+ self.tool_schemas = tool_schemas if tool_schemas is not None else TOOL_SCHEMAS
212
+ self.tool_executor = tool_executor if tool_executor is not None else execute_tool_call
213
+ self.augment = augment
214
+ self._known_tool_names = frozenset(s["function"]["name"] for s in self.tool_schemas)
215
+ self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
216
+
217
+ def reset(self) -> None:
218
+ """Drop history, starting a new conversation in the same session."""
219
+ self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]
220
+
221
+ async def send(self, user_input: str) -> str:
222
+ """Run one user turn to completion and return the model's final reply."""
223
+ self.messages.append({"role": "user", "content": user_input})
224
+
225
+ for _ in range(self.max_turns):
226
+ outgoing = await self.augment(self.messages) if self.augment else self.messages
227
+ payload = {"model": self.model, "messages": outgoing, "tools": self.tool_schemas}
228
+ if self.on_token is not None:
229
+ message = await _stream_completion(self.backend, payload, self.on_token)
230
+ else:
231
+ response = await self.backend.chat(payload)
232
+ message = response["choices"][0]["message"]
233
+
234
+ tool_calls = message.get("tool_calls") or []
235
+ if not tool_calls and message.get("content"):
236
+ synthetic = _as_synthetic_tool_call(message["content"], self._known_tool_names)
237
+ if synthetic is not None:
238
+ if self.on_event is not None:
239
+ self.on_event(
240
+ f"(model isn't using structured tool calls — "
241
+ f"parsed {synthetic['function']['name']} from plain text)"
242
+ )
243
+ message = {"role": "assistant", "content": None, "tool_calls": [synthetic]}
244
+ tool_calls = [synthetic]
245
+
246
+ self.messages.append(message)
247
+
248
+ if not tool_calls:
249
+ return message.get("content") or ""
250
+
251
+ for call in tool_calls:
252
+ self.messages.append(await self._run_tool_call(call))
253
+
254
+ raise AgentTurnLimitExceeded(
255
+ f"Stopped after {self.max_turns} turns without a final answer — "
256
+ "the model may be looping."
257
+ )
258
+
259
+ async def _run_tool_call(self, call: dict[str, Any]) -> dict[str, Any]:
260
+ fn = call["function"]
261
+ name = fn["name"]
262
+ arguments = _parse_arguments(fn.get("arguments", ""))
263
+
264
+ if name == "write_file" and self.confirm_write is not None:
265
+ path = arguments.get("path", "?")
266
+ if not self.confirm_write(path, arguments.get("content", "")):
267
+ return {
268
+ "role": "tool",
269
+ "tool_call_id": call["id"],
270
+ "name": name,
271
+ "content": f"User declined to write {path}. Ask before trying again.",
272
+ }
273
+
274
+ if self.on_event is not None:
275
+ args_repr = ", ".join(f"{k}={v!r}" for k, v in _summarize(arguments).items())
276
+ self.on_event(f"{name}({args_repr})")
277
+
278
+ result = self.tool_executor(self.root, call["id"], name, arguments)
279
+ return {
280
+ "role": "tool",
281
+ "tool_call_id": result.tool_call_id,
282
+ "name": result.name,
283
+ "content": result.content,
284
+ }
285
+
286
+
287
+ async def run_agent(
288
+ backend: InferenceBackend,
289
+ model: str,
290
+ root: Path,
291
+ task: str,
292
+ *,
293
+ confirm_write: ConfirmWrite | None = None,
294
+ on_event: OnEvent | None = None,
295
+ on_token: OnToken | None = None,
296
+ max_turns: int = 20,
297
+ augment: Augment | None = None,
298
+ ) -> str:
299
+ """Run one task to completion and return the model's final plain-text reply.
300
+
301
+ A convenience wrapper around a single-turn :class:`AgentSession`, for the
302
+ single-shot ``localgate code "task"`` invocation.
303
+ """
304
+ session = AgentSession(
305
+ backend,
306
+ model,
307
+ root,
308
+ confirm_write=confirm_write,
309
+ on_event=on_event,
310
+ on_token=on_token,
311
+ max_turns=max_turns,
312
+ augment=augment,
313
+ )
314
+ return await session.send(task)
315
+
316
+
317
+ def _summarize(arguments: dict[str, Any]) -> dict[str, Any]:
318
+ """Truncate long values (e.g. file content) so status lines stay one line."""
319
+ return {
320
+ k: (v if not isinstance(v, str) or len(v) <= 40 else v[:37] + "...")
321
+ for k, v in arguments.items()
322
+ }
@@ -0,0 +1,149 @@
1
+ """Wires the coding agent into the same conversation-history and RAG-memory
2
+ tables the HTTP `/v1/chat/completions` endpoint uses, so a coding session
3
+ survives across `localgate code` invocations the way an API client's
4
+ `X-Session-Id` does — retrievable later via the same `ConversationRepository`
5
+ `GET /v1/conversations/{session_id}` reads from.
6
+
7
+ The CLI talks to the backend directly, bypassing HTTP and API-key auth entirely
8
+ (see loop.py's module docstring) — but the conversation/memory tables have a
9
+ ``NOT NULL`` ``api_key_id`` foreign key. Rather than relax that constraint for
10
+ every caller, this module provisions one dedicated, reusable ``APIKey`` row to
11
+ own local CLI sessions; its raw key is discarded immediately since it's never
12
+ used to authenticate anything, only to satisfy the foreign key.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import uuid
18
+ from pathlib import Path
19
+
20
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
21
+
22
+ from localgate.backends.base import InferenceBackend
23
+ from localgate.config import Settings
24
+ from localgate.core.types import ChatMessage
25
+ from localgate.db.repositories.conversations import ConversationRepository, SummaryRepository
26
+ from localgate.db.repositories.embeddings import EmbeddingRepository
27
+ from localgate.db.repositories.keys import APIKeyRepository
28
+ from localgate.memory.chunker import chunk_text
29
+ from localgate.memory.context_builder import build_augmented_messages
30
+ from localgate.memory.embedder import embed_text
31
+ from localgate.memory.retriever import retrieve_relevant_context
32
+ from localgate.memory.summarizer import maybe_summarize
33
+
34
+ #: The one APIKey row every local `localgate code` session's history is attributed
35
+ #: to. Never sent anywhere as a credential.
36
+ LOCAL_AGENT_KEY_NAME = "localgate-code (local)"
37
+
38
+ _SESSION_MARKER_DIR = ".localgate"
39
+ _SESSION_MARKER_FILE = "session_id"
40
+
41
+
42
+ def project_session_id(root: Path) -> str:
43
+ """A session id for this project directory, minted once and reused.
44
+
45
+ Persisted at ``.localgate/session_id`` so re-running ``localgate code`` in the
46
+ same project resumes the same memory context automatically, per
47
+ CODING_AGENT_PLAN.md Phase 5.
48
+ """
49
+ marker = root / _SESSION_MARKER_DIR / _SESSION_MARKER_FILE
50
+ if marker.is_file():
51
+ existing = marker.read_text(encoding="utf-8").strip()
52
+ if existing:
53
+ return existing
54
+ session_id = str(uuid.uuid4())
55
+ marker.parent.mkdir(parents=True, exist_ok=True)
56
+ marker.write_text(session_id + "\n", encoding="utf-8")
57
+ return session_id
58
+
59
+
60
+ async def get_or_create_local_agent_key_id(session: AsyncSession, settings: Settings) -> str:
61
+ """The ``api_key_id`` local CLI sessions are attributed to."""
62
+ repo = APIKeyRepository(session)
63
+ for key in await repo.list_all():
64
+ if key.name == LOCAL_AGENT_KEY_NAME:
65
+ return key.id
66
+ key, _raw_key = await repo.create(LOCAL_AGENT_KEY_NAME, settings.default_rate_limit_per_min)
67
+ return key.id
68
+
69
+
70
+ class AgentMemory:
71
+ """Per-session memory: injects recalled context into outgoing turns, and
72
+ records each turn into conversation history, chunked and embedded, with
73
+ rolling summarization — the same three steps `chat.py` performs per request.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ session_factory: async_sessionmaker[AsyncSession],
79
+ backend: InferenceBackend,
80
+ settings: Settings,
81
+ session_id: str,
82
+ api_key_id: str,
83
+ ) -> None:
84
+ self._session_factory = session_factory
85
+ self._backend = backend
86
+ self._settings = settings
87
+ self.session_id = session_id
88
+ self._api_key_id = api_key_id
89
+
90
+ async def augment(self, messages: list[dict]) -> list[dict]:
91
+ """Retrieve context relevant to the latest user turn and inject it as a
92
+ framed system message, without mutating the caller's own history — the
93
+ same request-scoped augmentation `chat.py` does, applied in-process.
94
+ """
95
+ if not self._settings.memory_enabled:
96
+ return messages
97
+ last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
98
+ query = last_user.get("content") if last_user else None
99
+ if not isinstance(query, str) or not query:
100
+ return messages
101
+
102
+ async with self._session_factory() as db_session:
103
+ retrieved = await retrieve_relevant_context(
104
+ db_session,
105
+ self._backend,
106
+ self.session_id,
107
+ query,
108
+ self._settings.embedding_model,
109
+ top_k=self._settings.max_retrieved_chunks,
110
+ min_score=self._settings.memory_min_score,
111
+ )
112
+ summary = await SummaryRepository(db_session).latest(self.session_id)
113
+
114
+ if not retrieved and summary is None:
115
+ return messages
116
+
117
+ chat_messages = [ChatMessage(**m) for m in messages]
118
+ augmented = build_augmented_messages(
119
+ chat_messages, retrieved, summary.content if summary else None
120
+ )
121
+ return [m.model_dump(exclude_none=True) for m in augmented]
122
+
123
+ async def record_turn(self, user_text: str, assistant_text: str) -> None:
124
+ """Persist the turn, chunk and embed it, and fold it into the rolling
125
+ summary once the session has grown past `summarize_after_messages`.
126
+ """
127
+ async with self._session_factory() as db_session:
128
+ convo = ConversationRepository(db_session)
129
+ await convo.add_message(self.session_id, self._api_key_id, "user", user_text)
130
+ if assistant_text:
131
+ await convo.add_message(
132
+ self.session_id, self._api_key_id, "assistant", assistant_text
133
+ )
134
+
135
+ if not self._settings.memory_enabled:
136
+ return
137
+
138
+ exchange = f"User: {user_text}\nAssistant: {assistant_text}"
139
+ chunks = chunk_text(exchange, self._settings.chunk_size, self._settings.chunk_overlap)
140
+ embeddings_repo = EmbeddingRepository(db_session)
141
+ for chunk in chunks:
142
+ vector = await embed_text(self._backend, chunk, self._settings.embedding_model)
143
+ await embeddings_repo.add_chunk(
144
+ self.session_id, self._api_key_id, chunk, vector, kind="turn"
145
+ )
146
+
147
+ await maybe_summarize(
148
+ db_session, self._backend, self._settings, self.session_id, self._api_key_id
149
+ )