ollama-coding-agent 0.1.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.
- coding_agent/__init__.py +4 -0
- coding_agent/__main__.py +6 -0
- coding_agent/agent.py +316 -0
- coding_agent/cli.py +234 -0
- coding_agent/config.py +40 -0
- coding_agent/intent.py +138 -0
- coding_agent/mcp_client.py +88 -0
- coding_agent/mcp_server.py +109 -0
- coding_agent/ollama_client.py +73 -0
- coding_agent/session_store.py +141 -0
- coding_agent/tools.py +249 -0
- coding_agent/ui.py +224 -0
- ollama_coding_agent-0.1.0.dist-info/METADATA +222 -0
- ollama_coding_agent-0.1.0.dist-info/RECORD +18 -0
- ollama_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- ollama_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- ollama_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- ollama_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
coding_agent/intent.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Turn a freeform user request into structured intent — task type, target
|
|
2
|
+
files, constraints, risk level — before the agent starts taking actions.
|
|
3
|
+
|
|
4
|
+
This runs as a single, tool-free model call in strict JSON mode. It is
|
|
5
|
+
deliberately separate from the main agent loop: if it fails or the model
|
|
6
|
+
guesses wrong, the agent still runs — it just does so with less upfront
|
|
7
|
+
context, and a human reviewing the log can see exactly what was inferred.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import asyncio
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
from .ollama_client import chat, OllamaError
|
|
17
|
+
|
|
18
|
+
_CODE_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_code_fence(content: str) -> str:
|
|
22
|
+
"""Models sometimes wrap JSON-mode output in a ```json fence despite
|
|
23
|
+
being asked for a bare object — strip it before parsing."""
|
|
24
|
+
return _CODE_FENCE_RE.sub("", content.strip())
|
|
25
|
+
|
|
26
|
+
INTENT_SCHEMA_PROMPT = """You are a task-intent parser for a coding agent. \
|
|
27
|
+
Given a user's freeform request, output ONLY a JSON object (no markdown \
|
|
28
|
+
fences, no commentary, no explanation) with exactly these keys:
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
"task_type": one of "bugfix" | "feature" | "refactor" | "test" | "docs" | "explore" | "other",
|
|
32
|
+
"summary": a single sentence restating the task in your own words,
|
|
33
|
+
"target_files": array of file paths the user mentioned or clearly implied — best guess, can be empty,
|
|
34
|
+
"constraints": array of explicit requirements or limits the user stated (e.g. "don't touch the tests", "keep it under 100 lines") — can be empty,
|
|
35
|
+
"risk_level": "low" | "medium" | "high" — use "high" if the task involves deleting data, deploying, running migrations, force-pushing, or other hard-to-reverse actions
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Respond with the JSON object only."""
|
|
39
|
+
|
|
40
|
+
_VALID_TASK_TYPES = {"bugfix", "feature", "refactor", "test", "docs", "explore", "other"}
|
|
41
|
+
_VALID_RISK = {"low", "medium", "high"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Intent:
|
|
46
|
+
task_type: str = "other"
|
|
47
|
+
summary: str = ""
|
|
48
|
+
target_files: List[str] = field(default_factory=list)
|
|
49
|
+
constraints: List[str] = field(default_factory=list)
|
|
50
|
+
risk_level: str = "medium"
|
|
51
|
+
confident: bool = True # False if parsing fell back to the default
|
|
52
|
+
raw: dict = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def as_context_block(self, existing_files: dict = None) -> str:
|
|
55
|
+
"""Render as a short block to inject into the agent's messages.
|
|
56
|
+
`existing_files` optionally maps target file -> bool(exists) so the
|
|
57
|
+
agent knows upfront which targets are new vs. edits."""
|
|
58
|
+
files_line = "none specified"
|
|
59
|
+
if self.target_files:
|
|
60
|
+
parts = []
|
|
61
|
+
for f in self.target_files:
|
|
62
|
+
if existing_files is not None and f in existing_files:
|
|
63
|
+
tag = "exists" if existing_files[f] else "new"
|
|
64
|
+
parts.append(f"{f} ({tag})")
|
|
65
|
+
else:
|
|
66
|
+
parts.append(f)
|
|
67
|
+
files_line = ", ".join(parts)
|
|
68
|
+
|
|
69
|
+
constraints_line = "; ".join(self.constraints) if self.constraints else "none stated"
|
|
70
|
+
confidence_note = "" if self.confident else " [low confidence — intent parsing fell back to defaults]"
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
f"[Parsed intent]{confidence_note}\n"
|
|
74
|
+
f"task_type: {self.task_type}\n"
|
|
75
|
+
f"risk_level: {self.risk_level}\n"
|
|
76
|
+
f"summary: {self.summary}\n"
|
|
77
|
+
f"target_files: {files_line}\n"
|
|
78
|
+
f"constraints: {constraints_line}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce(data: dict) -> Intent:
|
|
83
|
+
task_type = data.get("task_type") if data.get("task_type") in _VALID_TASK_TYPES else "other"
|
|
84
|
+
risk_level = data.get("risk_level") if data.get("risk_level") in _VALID_RISK else "medium"
|
|
85
|
+
|
|
86
|
+
target_files = data.get("target_files") or []
|
|
87
|
+
if not isinstance(target_files, list):
|
|
88
|
+
target_files = [target_files]
|
|
89
|
+
|
|
90
|
+
constraints = data.get("constraints") or []
|
|
91
|
+
if not isinstance(constraints, list):
|
|
92
|
+
constraints = [constraints]
|
|
93
|
+
|
|
94
|
+
return Intent(
|
|
95
|
+
task_type=task_type,
|
|
96
|
+
summary=str(data.get("summary", ""))[:500],
|
|
97
|
+
target_files=[str(f) for f in target_files][:20],
|
|
98
|
+
constraints=[str(c) for c in constraints][:20],
|
|
99
|
+
risk_level=risk_level,
|
|
100
|
+
confident=True,
|
|
101
|
+
raw=data,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def extract_intent(task: str, model: str, max_retries: int = 3, logger=None, base_url: str = None, api_key: str = None) -> Intent:
|
|
106
|
+
"""Ask the model to parse `task` into structured intent. On repeated
|
|
107
|
+
failure, returns a low-confidence Intent(task_type='other') rather than
|
|
108
|
+
raising — callers should treat `confident=False` as a signal to fall
|
|
109
|
+
back to plain freeform behavior, not as ground truth to act on."""
|
|
110
|
+
messages = [
|
|
111
|
+
{"role": "system", "content": INTENT_SCHEMA_PROMPT},
|
|
112
|
+
{"role": "user", "content": task},
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
last_err = None
|
|
116
|
+
for attempt in range(1, max_retries + 1):
|
|
117
|
+
try:
|
|
118
|
+
message = await chat(model=model, messages=messages, format="json", base_url=base_url, api_key=api_key)
|
|
119
|
+
content = message["content"]
|
|
120
|
+
data = json.loads(_strip_code_fence(content))
|
|
121
|
+
intent = _coerce(data)
|
|
122
|
+
if logger:
|
|
123
|
+
logger.info(f"INTENT parsed (attempt {attempt}): {data}")
|
|
124
|
+
return intent
|
|
125
|
+
except (json.JSONDecodeError, KeyError, OllamaError) as e:
|
|
126
|
+
last_err = e
|
|
127
|
+
if logger:
|
|
128
|
+
logger.info(f"INTENT parse failed (attempt {attempt}): {e}")
|
|
129
|
+
await asyncio.sleep(min(2 ** attempt, 5))
|
|
130
|
+
except Exception as e:
|
|
131
|
+
last_err = e
|
|
132
|
+
if logger:
|
|
133
|
+
logger.info(f"INTENT unexpected error (attempt {attempt}): {e}")
|
|
134
|
+
await asyncio.sleep(1)
|
|
135
|
+
|
|
136
|
+
if logger:
|
|
137
|
+
logger.info(f"INTENT parsing gave up after {max_retries} attempts ({last_err}); using fallback.")
|
|
138
|
+
return Intent(summary=task[:200], confident=False, raw={})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Thin async MCP client: spawns mcp_server.py as a subprocess scoped to a
|
|
2
|
+
project root, and adapts its tool list into the Ollama function-calling
|
|
3
|
+
schema format the agent already knows how to use.
|
|
4
|
+
|
|
5
|
+
This is the only file that changed how the agent talks to its tools —
|
|
6
|
+
everything else (approval flow, logging, the loop shape) is unchanged;
|
|
7
|
+
it just goes through an MCP session now instead of direct Python calls.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from contextlib import AsyncExitStack
|
|
13
|
+
|
|
14
|
+
from mcp import ClientSession
|
|
15
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mcp_schema_to_ollama(tool) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"type": "function",
|
|
21
|
+
"function": {
|
|
22
|
+
"name": tool.name,
|
|
23
|
+
"description": tool.description or "",
|
|
24
|
+
"parameters": tool.inputSchema or {"type": "object", "properties": {}},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MCPToolClient:
|
|
30
|
+
"""Use as an async context manager, one instance per agent run:
|
|
31
|
+
|
|
32
|
+
async with MCPToolClient(project_root) as client:
|
|
33
|
+
schemas = await client.list_llm_tools()
|
|
34
|
+
result = await client.call_tool("edit_file", {...})
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_root: str, server_path: str = None):
|
|
38
|
+
self.project_root = project_root
|
|
39
|
+
# Default: run the server as `python -m <package>.mcp_server` rather
|
|
40
|
+
# than by file path — mcp_server.py uses relative imports (it's part
|
|
41
|
+
# of this package), which only resolve when it's launched as a
|
|
42
|
+
# module, not executed as a standalone script. `server_path` is an
|
|
43
|
+
# escape hatch for pointing at a different server entirely.
|
|
44
|
+
self.server_args = [server_path] if server_path else ["-m", f"{__package__}.mcp_server"]
|
|
45
|
+
self._stack = AsyncExitStack()
|
|
46
|
+
self.session: ClientSession = None
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self):
|
|
49
|
+
params = StdioServerParameters(
|
|
50
|
+
command=sys.executable,
|
|
51
|
+
args=self.server_args,
|
|
52
|
+
env={**os.environ, "AGENT_PROJECT_ROOT": self.project_root},
|
|
53
|
+
)
|
|
54
|
+
read, write = await self._stack.enter_async_context(stdio_client(params))
|
|
55
|
+
self.session = await self._stack.enter_async_context(ClientSession(read, write))
|
|
56
|
+
await self.session.initialize()
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __aexit__(self, *exc_info):
|
|
60
|
+
await self._stack.aclose()
|
|
61
|
+
|
|
62
|
+
async def list_llm_tools(self) -> list:
|
|
63
|
+
"""Schemas for tools the LLM is allowed to call — internal
|
|
64
|
+
underscore-prefixed tools (previews, existence checks) are held
|
|
65
|
+
back; the agent still calls those directly for its own logic."""
|
|
66
|
+
result = await self.session.list_tools()
|
|
67
|
+
return [_mcp_schema_to_ollama(t) for t in result.tools if not t.name.startswith("_")]
|
|
68
|
+
|
|
69
|
+
async def call_tool(self, name: str, args: dict) -> str:
|
|
70
|
+
result = await self.session.call_tool(name, args)
|
|
71
|
+
text = "".join(c.text for c in result.content if hasattr(c, "text"))
|
|
72
|
+
return f"ERROR: {text}" if result.isError else text
|
|
73
|
+
|
|
74
|
+
async def preview_edit(self, path: str, old_str: str, new_str: str):
|
|
75
|
+
raw = await self.call_tool("_preview_edit", {"path": path, "old_str": old_str, "new_str": new_str})
|
|
76
|
+
ok = raw.startswith("OK\n")
|
|
77
|
+
msg = raw.split("\n", 1)[1] if "\n" in raw else raw
|
|
78
|
+
return ok, msg
|
|
79
|
+
|
|
80
|
+
async def preview_write(self, path: str, content: str, overwrite: bool = False):
|
|
81
|
+
raw = await self.call_tool("_preview_write", {"path": path, "content": content, "overwrite": overwrite})
|
|
82
|
+
is_new = raw.startswith("NEW\n")
|
|
83
|
+
preview = raw.split("\n", 1)[1] if "\n" in raw else raw
|
|
84
|
+
return is_new, preview
|
|
85
|
+
|
|
86
|
+
async def file_exists(self, path: str) -> bool:
|
|
87
|
+
raw = await self.call_tool("_file_exists", {"path": path})
|
|
88
|
+
return raw.strip() == "true"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""MCP server exposing the sandboxed coding tools.
|
|
2
|
+
|
|
3
|
+
Run standalone to test with any MCP client:
|
|
4
|
+
AGENT_PROJECT_ROOT=/path/to/repo python mcp_server.py
|
|
5
|
+
|
|
6
|
+
Any MCP-compatible client (not just this agent) can now use these tools —
|
|
7
|
+
Claude Desktop, another agent framework, etc. — all sharing the same
|
|
8
|
+
sandbox/approval-preview logic in tools.py.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from .config import AgentConfig
|
|
16
|
+
from .tools import Tools
|
|
17
|
+
|
|
18
|
+
PROJECT_ROOT = os.environ.get("AGENT_PROJECT_ROOT", ".")
|
|
19
|
+
|
|
20
|
+
cfg = AgentConfig(project_root=PROJECT_ROOT)
|
|
21
|
+
impl = Tools(cfg)
|
|
22
|
+
|
|
23
|
+
mcp = FastMCP("coding-agent-tools")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---- Tools exposed to any MCP client (these are what the LLM sees) ----
|
|
27
|
+
|
|
28
|
+
@mcp.tool()
|
|
29
|
+
def read_file(path: str, start_line: int = None, end_line: int = None) -> str:
|
|
30
|
+
"""Read a file, optionally a specific line range."""
|
|
31
|
+
return impl.read_file(path, start_line, end_line)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def list_dir(path: str = ".") -> str:
|
|
36
|
+
"""List files in a directory."""
|
|
37
|
+
return impl.list_dir(path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
def search_files(pattern: str, path: str = ".", glob: str = None, case_insensitive: bool = False) -> str:
|
|
42
|
+
"""Regex search across files under a path, skipping noise directories
|
|
43
|
+
(.git, node_modules, __pycache__, build output, etc.). Optionally
|
|
44
|
+
restrict to filenames matching `glob` (e.g. "*.py")."""
|
|
45
|
+
return impl.search_files(pattern, path, glob, case_insensitive)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def glob_files(pattern: str, path: str = ".") -> str:
|
|
50
|
+
"""Find files by glob pattern, e.g. "**/*.tsx" or "src/**/test_*.py".
|
|
51
|
+
Results are sorted newest-first. Use this to discover files by name/
|
|
52
|
+
location; use search_files to find files by content."""
|
|
53
|
+
return impl.glob_files(pattern, path)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def git_diff(path: str = ".") -> str:
|
|
58
|
+
"""Show uncommitted git changes in the project."""
|
|
59
|
+
return impl.git_diff(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def write_file(path: str, content: str, overwrite: bool = False) -> str:
|
|
64
|
+
"""Create a NEW file with content. Fails if the file already exists
|
|
65
|
+
unless overwrite=true. Use edit_file for existing files."""
|
|
66
|
+
return impl.write_file(path, content, overwrite)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def edit_file(path: str, old_str: str, new_str: str) -> str:
|
|
71
|
+
"""Replace an exact, unique block of text in an existing file. old_str
|
|
72
|
+
must match precisely (include enough context to be unique)."""
|
|
73
|
+
return impl.edit_file(path, old_str, new_str)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def run_shell(command: str) -> str:
|
|
78
|
+
"""Run a shell command in the project root. Dangerous commands are
|
|
79
|
+
blocked by policy."""
|
|
80
|
+
return impl.run_shell(command)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- Internal tools: dry-run previews for the approval UI + existence
|
|
84
|
+
# checks for intent validation. Named with a leading underscore so the
|
|
85
|
+
# client can filter them out of what it hands to the LLM, while still
|
|
86
|
+
# calling them directly for its own approval-flow logic. ----
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def _preview_edit(path: str, old_str: str, new_str: str) -> str:
|
|
90
|
+
"""Internal: dry-run diff preview for edit_file, no write performed."""
|
|
91
|
+
ok, msg = impl.preview_edit(path, old_str, new_str)
|
|
92
|
+
return f"OK\n{msg}" if ok else f"ERROR\n{msg}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
def _preview_write(path: str, content: str, overwrite: bool = False) -> str:
|
|
97
|
+
"""Internal: dry-run preview for write_file, no write performed."""
|
|
98
|
+
is_new, preview = impl.preview_write(path, content, overwrite)
|
|
99
|
+
return f"{'NEW' if is_new else 'DIFF'}\n{preview}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def _file_exists(path: str) -> str:
|
|
104
|
+
"""Internal: sandboxed existence check for intent validation."""
|
|
105
|
+
return "true" if impl.file_exists(path) else "false"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
mcp.run() # stdio transport by default
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Talks to Ollama directly over HTTP (/api/chat) — no `ollama` python
|
|
2
|
+
package required, just httpx. Mirrors the message shape the rest of the
|
|
3
|
+
agent already expects: chat() returns the `message` dict with
|
|
4
|
+
role/content/tool_calls, same as the ollama library did.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
|
12
|
+
DEFAULT_API_KEY = os.environ.get("OLLAMA_API_KEY", "") # set via env, never hardcode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OllamaError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def chat(
|
|
20
|
+
model: str,
|
|
21
|
+
messages: list,
|
|
22
|
+
tools: list = None,
|
|
23
|
+
format: str = None,
|
|
24
|
+
base_url: str = None,
|
|
25
|
+
api_key: str = None,
|
|
26
|
+
timeout: float = 120.0,
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""POST /api/v1/chat/completions (OpenAI-compatible) with stream=false
|
|
29
|
+
and return the `message` dict.
|
|
30
|
+
|
|
31
|
+
This hits our Open WebUI gateway in front of Ollama, not Ollama's native
|
|
32
|
+
/api/chat — the gateway only exposes the OpenAI-style route. Response
|
|
33
|
+
shape is `choices[0].message`, but that dict already has the
|
|
34
|
+
role/content/tool_calls keys the rest of the agent expects, same as
|
|
35
|
+
Ollama's native `message` field would.
|
|
36
|
+
|
|
37
|
+
If an API key is set (via `api_key`, falling back to $OLLAMA_API_KEY),
|
|
38
|
+
it's sent as `Authorization: Bearer <key>`.
|
|
39
|
+
|
|
40
|
+
Raises OllamaError on a non-2xx response, a connection failure, or an
|
|
41
|
+
unexpected response shape — callers (agent.py, intent.py) already retry
|
|
42
|
+
on this.
|
|
43
|
+
"""
|
|
44
|
+
url = f"{(base_url or DEFAULT_BASE_URL).rstrip('/')}/api/v1/chat/completions"
|
|
45
|
+
payload = {"model": model, "messages": messages, "stream": False}
|
|
46
|
+
if tools:
|
|
47
|
+
payload["tools"] = tools
|
|
48
|
+
if format == "json":
|
|
49
|
+
payload["response_format"] = {"type": "json_object"}
|
|
50
|
+
|
|
51
|
+
key = api_key or DEFAULT_API_KEY
|
|
52
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
56
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
data = resp.json()
|
|
59
|
+
except httpx.HTTPStatusError as e:
|
|
60
|
+
body = e.response.text[:300]
|
|
61
|
+
raise OllamaError(f"Ollama API returned {e.response.status_code} for {model}: {body}") from e
|
|
62
|
+
except httpx.RequestError as e:
|
|
63
|
+
raise OllamaError(
|
|
64
|
+
f"Could not reach Ollama at {url} ({e}). Is `ollama serve` running?"
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
choices = data.get("choices")
|
|
68
|
+
if not choices:
|
|
69
|
+
raise OllamaError(f"Unexpected response shape from Ollama (no 'choices' key): {data}")
|
|
70
|
+
message = choices[0].get("message")
|
|
71
|
+
if message is None:
|
|
72
|
+
raise OllamaError(f"Unexpected response shape from Ollama (no 'message' key): {data}")
|
|
73
|
+
return message
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""SQLite-backed persistence for agent sessions and their full message
|
|
2
|
+
history, so a run can be resumed later (`--resume <id>`) or browsed
|
|
3
|
+
(`--list-sessions`) instead of always starting from a blank conversation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _now() -> str:
|
|
13
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _connect(db_path: str) -> sqlite3.Connection:
|
|
17
|
+
conn = sqlite3.connect(db_path)
|
|
18
|
+
conn.row_factory = sqlite3.Row
|
|
19
|
+
return conn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionStore:
|
|
23
|
+
"""One instance per agent process. Every message appended to the
|
|
24
|
+
in-memory conversation is mirrored here in the same order, so
|
|
25
|
+
`load_messages` reconstructs exactly what the model saw."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, db_path: str):
|
|
28
|
+
self.db_path = db_path
|
|
29
|
+
with _connect(self.db_path) as conn:
|
|
30
|
+
conn.execute("""
|
|
31
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
created_at TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT NOT NULL,
|
|
35
|
+
project_root TEXT NOT NULL,
|
|
36
|
+
model TEXT NOT NULL,
|
|
37
|
+
task TEXT NOT NULL,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
39
|
+
summary TEXT,
|
|
40
|
+
name TEXT
|
|
41
|
+
)
|
|
42
|
+
""")
|
|
43
|
+
conn.execute("""
|
|
44
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
47
|
+
seq INTEGER NOT NULL,
|
|
48
|
+
role TEXT NOT NULL,
|
|
49
|
+
content TEXT,
|
|
50
|
+
tool_calls TEXT,
|
|
51
|
+
created_at TEXT NOT NULL
|
|
52
|
+
)
|
|
53
|
+
""")
|
|
54
|
+
existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(sessions)")}
|
|
55
|
+
if "name" not in existing_cols:
|
|
56
|
+
conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
|
|
57
|
+
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_name ON sessions(name)")
|
|
58
|
+
|
|
59
|
+
def create_session(self, project_root: str, model: str, task: str, name: str = None) -> str:
|
|
60
|
+
session_id = uuid.uuid4().hex[:8]
|
|
61
|
+
now = _now()
|
|
62
|
+
try:
|
|
63
|
+
with _connect(self.db_path) as conn:
|
|
64
|
+
conn.execute(
|
|
65
|
+
"INSERT INTO sessions (id, created_at, updated_at, project_root, model, task, status, name) "
|
|
66
|
+
"VALUES (?, ?, ?, ?, ?, ?, 'running', ?)",
|
|
67
|
+
(session_id, now, now, project_root, model, task, name),
|
|
68
|
+
)
|
|
69
|
+
except sqlite3.IntegrityError:
|
|
70
|
+
raise ValueError(f"Session name {name!r} is already in use — pick another with --session-name.")
|
|
71
|
+
return session_id
|
|
72
|
+
|
|
73
|
+
def session_exists(self, session_id: str) -> bool:
|
|
74
|
+
with _connect(self.db_path) as conn:
|
|
75
|
+
row = conn.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
76
|
+
return row is not None
|
|
77
|
+
|
|
78
|
+
def resolve_session_id(self, id_or_name: str) -> str:
|
|
79
|
+
"""Accept either a session id or a session --session-name and return
|
|
80
|
+
the underlying id, or None if neither matches."""
|
|
81
|
+
with _connect(self.db_path) as conn:
|
|
82
|
+
row = conn.execute(
|
|
83
|
+
"SELECT id FROM sessions WHERE id = ? OR name = ?", (id_or_name, id_or_name),
|
|
84
|
+
).fetchone()
|
|
85
|
+
return row["id"] if row else None
|
|
86
|
+
|
|
87
|
+
def load_messages(self, session_id: str) -> list:
|
|
88
|
+
with _connect(self.db_path) as conn:
|
|
89
|
+
rows = conn.execute(
|
|
90
|
+
"SELECT role, content, tool_calls FROM messages WHERE session_id = ? ORDER BY seq",
|
|
91
|
+
(session_id,),
|
|
92
|
+
).fetchall()
|
|
93
|
+
messages = []
|
|
94
|
+
for row in rows:
|
|
95
|
+
msg = {"role": row["role"], "content": row["content"]}
|
|
96
|
+
if row["tool_calls"]:
|
|
97
|
+
msg["tool_calls"] = json.loads(row["tool_calls"])
|
|
98
|
+
messages.append(msg)
|
|
99
|
+
return messages
|
|
100
|
+
|
|
101
|
+
def append_message(self, session_id: str, seq: int, message: dict) -> None:
|
|
102
|
+
tool_calls = message.get("tool_calls")
|
|
103
|
+
now = _now()
|
|
104
|
+
with _connect(self.db_path) as conn:
|
|
105
|
+
conn.execute(
|
|
106
|
+
"INSERT INTO messages (session_id, seq, role, content, tool_calls, created_at) "
|
|
107
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
108
|
+
(
|
|
109
|
+
session_id, seq, message.get("role", ""), message.get("content"),
|
|
110
|
+
json.dumps(tool_calls) if tool_calls else None, now,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
conn.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (now, session_id))
|
|
114
|
+
|
|
115
|
+
def finish_session(self, session_id: str, status: str, summary: str) -> None:
|
|
116
|
+
with _connect(self.db_path) as conn:
|
|
117
|
+
conn.execute(
|
|
118
|
+
"UPDATE sessions SET status = ?, summary = ?, updated_at = ? WHERE id = ?",
|
|
119
|
+
(status, summary, _now(), session_id),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def delete_session(self, id_or_name: str) -> bool:
|
|
123
|
+
"""Delete a session and its full message history. Returns False if
|
|
124
|
+
no session matches `id_or_name` (id or --session-name), True if
|
|
125
|
+
deleted."""
|
|
126
|
+
session_id = self.resolve_session_id(id_or_name)
|
|
127
|
+
if session_id is None:
|
|
128
|
+
return False
|
|
129
|
+
with _connect(self.db_path) as conn:
|
|
130
|
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
131
|
+
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def list_sessions(self, limit: int = 20) -> list:
|
|
135
|
+
with _connect(self.db_path) as conn:
|
|
136
|
+
rows = conn.execute(
|
|
137
|
+
"SELECT id, created_at, updated_at, project_root, model, task, status, summary, name "
|
|
138
|
+
"FROM sessions ORDER BY updated_at DESC LIMIT ?",
|
|
139
|
+
(limit,),
|
|
140
|
+
).fetchall()
|
|
141
|
+
return [dict(r) for r in rows]
|