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
localgate/__init__.py
ADDED
localgate/__main__.py
ADDED
|
@@ -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)
|
localgate/agent/loop.py
ADDED
|
@@ -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
|
+
)
|