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/__init__.py
ADDED
coding_agent/__main__.py
ADDED
coding_agent/agent.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Agent loop: model <-> MCP tool server, with the guardrails a first draft skips.
|
|
2
|
+
|
|
3
|
+
Tools now live in mcp_server.py and are reached through mcp_client.MCPToolClient
|
|
4
|
+
rather than being called directly — so the loop itself is async (an MCP
|
|
5
|
+
session is async under the hood).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from contextlib import nullcontext
|
|
13
|
+
|
|
14
|
+
from .ollama_client import chat, OllamaError
|
|
15
|
+
|
|
16
|
+
from .config import AgentConfig
|
|
17
|
+
from .intent import extract_intent
|
|
18
|
+
from .mcp_client import MCPToolClient
|
|
19
|
+
from .session_store import SessionStore
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from . import ui
|
|
23
|
+
_HAS_UI = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
_HAS_UI = False
|
|
26
|
+
|
|
27
|
+
SYSTEM_PROMPT = """You are a coding agent working inside a sandboxed project \
|
|
28
|
+
directory. You have tools to read, search, write, and edit files, check git \
|
|
29
|
+
diffs, and run shell commands.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Prefer edit_file over write_file for existing files — write_file will \
|
|
33
|
+
refuse to overwrite unless you pass overwrite=true.
|
|
34
|
+
- Before running anything destructive or irreversible, check git_diff or \
|
|
35
|
+
read_file first so you understand current state.
|
|
36
|
+
- Keep changes minimal and focused on the task.
|
|
37
|
+
- When the task is fully done, reply with plain text (no tool call) \
|
|
38
|
+
summarizing what changed and how to verify it (e.g. which command to run).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _setup_logger(log_path: str) -> logging.Logger:
|
|
43
|
+
logger = logging.getLogger("coding_agent")
|
|
44
|
+
logger.setLevel(logging.INFO)
|
|
45
|
+
logger.handlers.clear()
|
|
46
|
+
fh = logging.FileHandler(log_path, encoding="utf-8")
|
|
47
|
+
fh.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
48
|
+
logger.addHandler(fh)
|
|
49
|
+
return logger
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _approve(tool_name: str, args: dict, cfg: AgentConfig, client: MCPToolClient, force_approval: bool = False) -> bool:
|
|
53
|
+
if tool_name in cfg.safe_tools:
|
|
54
|
+
return True
|
|
55
|
+
if cfg.auto_approve and not force_approval:
|
|
56
|
+
return True
|
|
57
|
+
if _HAS_UI:
|
|
58
|
+
return await ui.request_approval(tool_name, args, client)
|
|
59
|
+
print(f"\n--- Approval needed: {tool_name} ---")
|
|
60
|
+
print(json.dumps(args, indent=2)[:2000])
|
|
61
|
+
answer = input("Proceed? [y/N] ").strip().lower()
|
|
62
|
+
return answer == "y"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_json_objects(text: str) -> list:
|
|
66
|
+
"""Scan text for top-level {...} objects, tracking string-literal state
|
|
67
|
+
so braces inside quoted content (e.g. code the model is trying to write)
|
|
68
|
+
don't throw off the balance count."""
|
|
69
|
+
objs = []
|
|
70
|
+
i, n = 0, len(text)
|
|
71
|
+
while i < n:
|
|
72
|
+
if text[i] == "{":
|
|
73
|
+
depth, in_str, esc, j = 0, False, False, i
|
|
74
|
+
while j < n:
|
|
75
|
+
c = text[j]
|
|
76
|
+
if in_str:
|
|
77
|
+
if esc:
|
|
78
|
+
esc = False
|
|
79
|
+
elif c == "\\":
|
|
80
|
+
esc = True
|
|
81
|
+
elif c == '"':
|
|
82
|
+
in_str = False
|
|
83
|
+
elif c == '"':
|
|
84
|
+
in_str = True
|
|
85
|
+
elif c == "{":
|
|
86
|
+
depth += 1
|
|
87
|
+
elif c == "}":
|
|
88
|
+
depth -= 1
|
|
89
|
+
if depth == 0:
|
|
90
|
+
objs.append(text[i:j + 1])
|
|
91
|
+
break
|
|
92
|
+
j += 1
|
|
93
|
+
i = j + 1
|
|
94
|
+
else:
|
|
95
|
+
i += 1
|
|
96
|
+
return objs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _recover_text_tool_calls(content: str, tool_names: set) -> list:
|
|
100
|
+
"""Some models print a tool call as plain-text JSON (`{"name": ...,
|
|
101
|
+
"arguments": {...}}`) instead of using the tool-calling API, which would
|
|
102
|
+
otherwise look like a final answer and silently end the run without the
|
|
103
|
+
tool ever executing. Recover any such calls from `content`."""
|
|
104
|
+
if not content or "{" not in content:
|
|
105
|
+
return []
|
|
106
|
+
calls = []
|
|
107
|
+
for raw in _find_json_objects(content):
|
|
108
|
+
try:
|
|
109
|
+
obj = json.loads(raw)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
continue
|
|
112
|
+
name, args = obj.get("name"), obj.get("arguments")
|
|
113
|
+
if name in tool_names and isinstance(args, dict):
|
|
114
|
+
calls.append({
|
|
115
|
+
"id": f"fallback_{len(calls)}",
|
|
116
|
+
"type": "function",
|
|
117
|
+
"function": {"name": name, "arguments": args},
|
|
118
|
+
})
|
|
119
|
+
return calls
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _trim_history(messages: list, budget: int) -> list:
|
|
123
|
+
"""Keep the system + user task message plus the most recent turns
|
|
124
|
+
within a rough character budget. Crude but effective without pulling
|
|
125
|
+
in a tokenizer dependency."""
|
|
126
|
+
total = sum(len(str(m.get("content", ""))) for m in messages)
|
|
127
|
+
if total <= budget:
|
|
128
|
+
return messages
|
|
129
|
+
head, tail = messages[:2], messages[2:]
|
|
130
|
+
while tail and total > budget:
|
|
131
|
+
removed = tail.pop(0)
|
|
132
|
+
total -= len(str(removed.get("content", "")))
|
|
133
|
+
return head + tail
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CodingAgent:
|
|
137
|
+
def __init__(self, cfg: AgentConfig):
|
|
138
|
+
self.cfg = cfg
|
|
139
|
+
self.logger = _setup_logger(cfg.log_path)
|
|
140
|
+
self.force_approval = False # set True for the run if intent is high-risk
|
|
141
|
+
self.store = SessionStore(cfg.db_path)
|
|
142
|
+
self.session_id = None # set by run() to whichever session the last turn used
|
|
143
|
+
|
|
144
|
+
async def _call_model(self, messages: list, tool_schemas: list):
|
|
145
|
+
"""Call Ollama with retries for transient errors (connection refused,
|
|
146
|
+
5xx, malformed tool-call output — small local models occasionally
|
|
147
|
+
emit broken JSON)."""
|
|
148
|
+
last_err = None
|
|
149
|
+
spinner = ui.thinking() if _HAS_UI else nullcontext()
|
|
150
|
+
with spinner:
|
|
151
|
+
for attempt in range(1, self.cfg.max_retries + 1):
|
|
152
|
+
try:
|
|
153
|
+
return await chat(model=self.cfg.model, messages=messages, tools=tool_schemas,
|
|
154
|
+
base_url=self.cfg.ollama_host, api_key=self.cfg.ollama_api_key)
|
|
155
|
+
except OllamaError as e:
|
|
156
|
+
last_err = e
|
|
157
|
+
self.logger.info(f"model call failed (attempt {attempt}): {e}")
|
|
158
|
+
if _HAS_UI:
|
|
159
|
+
spinner.update(f"[bold yellow]Thinking… (retry {attempt}/{self.cfg.max_retries})[/bold yellow]")
|
|
160
|
+
await asyncio.sleep(min(2 ** attempt, 10))
|
|
161
|
+
except Exception as e:
|
|
162
|
+
last_err = e
|
|
163
|
+
self.logger.info(f"unexpected error (attempt {attempt}): {e}")
|
|
164
|
+
if _HAS_UI:
|
|
165
|
+
spinner.update(f"[bold yellow]Thinking… (retry {attempt}/{self.cfg.max_retries})[/bold yellow]")
|
|
166
|
+
await asyncio.sleep(1)
|
|
167
|
+
raise RuntimeError(f"Model call failed after {self.cfg.max_retries} attempts: {last_err}")
|
|
168
|
+
|
|
169
|
+
async def run(self, task: str = "", resume_session_id: str = None, client: MCPToolClient = None,
|
|
170
|
+
session_name: str = None, show_banner: bool = True) -> str:
|
|
171
|
+
"""Run one turn of the agent loop. If `client` is given (an already
|
|
172
|
+
-open MCPToolClient), it's reused instead of spawning a fresh MCP
|
|
173
|
+
server subprocess — used by the interactive REPL so each turn
|
|
174
|
+
doesn't pay subprocess-startup cost. `self.session_id` is set to
|
|
175
|
+
whichever session this turn ran against, so callers (e.g. the REPL)
|
|
176
|
+
can pass it back in as `resume_session_id` on the next turn.
|
|
177
|
+
`resume_session_id` accepts either a session id or a --session-name.
|
|
178
|
+
`session_name` optionally names a newly-created session. Pass
|
|
179
|
+
`show_banner=False` when a caller (e.g. the REPL) already prints its
|
|
180
|
+
own header and doesn't want one repeated every turn."""
|
|
181
|
+
resuming = resume_session_id is not None
|
|
182
|
+
|
|
183
|
+
if resuming:
|
|
184
|
+
session_id = self.store.resolve_session_id(resume_session_id)
|
|
185
|
+
if session_id is None:
|
|
186
|
+
raise ValueError(f"No session found with id or name {resume_session_id!r}")
|
|
187
|
+
messages = self.store.load_messages(session_id)
|
|
188
|
+
persisted = len(messages) # already in the DB, don't re-write these
|
|
189
|
+
label = task or "[continuing previous task]"
|
|
190
|
+
if _HAS_UI and show_banner:
|
|
191
|
+
ui.banner(f"(resumed {session_id}) {label}", self.cfg.model)
|
|
192
|
+
self.logger.info(f"RESUME session={session_id} TASK: {label}")
|
|
193
|
+
if task:
|
|
194
|
+
messages.append({"role": "user", "content": task})
|
|
195
|
+
else:
|
|
196
|
+
if _HAS_UI and show_banner:
|
|
197
|
+
ui.banner(task, self.cfg.model)
|
|
198
|
+
session_id = self.store.create_session(self.cfg.project_root, self.cfg.model, task, name=session_name)
|
|
199
|
+
messages = [
|
|
200
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
201
|
+
{"role": "user", "content": task},
|
|
202
|
+
]
|
|
203
|
+
persisted = 0
|
|
204
|
+
self.logger.info(f"TASK: {task} (session={session_id})")
|
|
205
|
+
|
|
206
|
+
self.session_id = session_id
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if client is not None:
|
|
210
|
+
return await self._run_loop(task, session_id, messages, persisted, resuming, client)
|
|
211
|
+
async with MCPToolClient(self.cfg.project_root) as owned_client:
|
|
212
|
+
return await self._run_loop(task, session_id, messages, persisted, resuming, owned_client)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
self.store.finish_session(session_id, "error", str(e))
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
async def _run_loop(self, task: str, session_id: str, messages: list, persisted: int,
|
|
218
|
+
resuming: bool, client: MCPToolClient) -> str:
|
|
219
|
+
tool_schemas = await client.list_llm_tools()
|
|
220
|
+
tool_names = {t["function"]["name"] for t in tool_schemas}
|
|
221
|
+
|
|
222
|
+
if not resuming and self.cfg.parse_intent:
|
|
223
|
+
intent_model = self.cfg.intent_model or self.cfg.model
|
|
224
|
+
spinner = ui.thinking("Parsing intent…") if _HAS_UI else nullcontext()
|
|
225
|
+
with spinner:
|
|
226
|
+
intent = await extract_intent(task, intent_model, self.cfg.max_retries, self.logger,
|
|
227
|
+
base_url=self.cfg.ollama_host, api_key=self.cfg.ollama_api_key)
|
|
228
|
+
|
|
229
|
+
existing = {f: await client.file_exists(f) for f in intent.target_files}
|
|
230
|
+
context_block = intent.as_context_block(existing)
|
|
231
|
+
messages.insert(1, {"role": "system", "content": context_block})
|
|
232
|
+
|
|
233
|
+
if _HAS_UI:
|
|
234
|
+
ui.intent_panel(intent, existing)
|
|
235
|
+
else:
|
|
236
|
+
print(f"\n{context_block}\n")
|
|
237
|
+
|
|
238
|
+
if intent.risk_level == "high":
|
|
239
|
+
self.force_approval = True
|
|
240
|
+
warning = "High-risk intent detected — approval required for all write/shell actions this run, even with --auto-approve."
|
|
241
|
+
if _HAS_UI:
|
|
242
|
+
ui.high_risk_warning()
|
|
243
|
+
else:
|
|
244
|
+
print(f"⚠️ {warning}")
|
|
245
|
+
self.logger.info(warning)
|
|
246
|
+
|
|
247
|
+
for m in messages[persisted:]:
|
|
248
|
+
self.store.append_message(session_id, persisted, m)
|
|
249
|
+
persisted += 1
|
|
250
|
+
|
|
251
|
+
for step in range(1, self.cfg.max_steps + 1):
|
|
252
|
+
messages = _trim_history(messages, self.cfg.context_char_budget)
|
|
253
|
+
msg = await self._call_model(messages, tool_schemas)
|
|
254
|
+
|
|
255
|
+
tool_calls = msg.get("tool_calls")
|
|
256
|
+
if not tool_calls:
|
|
257
|
+
recovered = _recover_text_tool_calls(msg.get("content", ""), tool_names)
|
|
258
|
+
if recovered:
|
|
259
|
+
msg["tool_calls"] = recovered
|
|
260
|
+
tool_calls = recovered
|
|
261
|
+
self.logger.info(
|
|
262
|
+
f"[step {step}] model printed tool call as plain text; "
|
|
263
|
+
f"recovered {len(recovered)} call(s) via fallback parsing"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
messages.append(msg)
|
|
267
|
+
self.store.append_message(session_id, persisted, msg)
|
|
268
|
+
persisted += 1
|
|
269
|
+
|
|
270
|
+
if not tool_calls:
|
|
271
|
+
final = msg.get("content", "")
|
|
272
|
+
self.logger.info(f"DONE: {final}")
|
|
273
|
+
self.store.finish_session(session_id, "done", final)
|
|
274
|
+
return final
|
|
275
|
+
|
|
276
|
+
for call in tool_calls:
|
|
277
|
+
name = call["function"]["name"]
|
|
278
|
+
args = call["function"]["arguments"]
|
|
279
|
+
if isinstance(args, str):
|
|
280
|
+
try:
|
|
281
|
+
args = json.loads(args)
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
result = f"ERROR: model sent malformed arguments: {args!r}"
|
|
284
|
+
messages.append({"role": "tool", "content": result})
|
|
285
|
+
self.store.append_message(session_id, persisted, messages[-1])
|
|
286
|
+
persisted += 1
|
|
287
|
+
self.logger.info(f"[step {step}] {name} -> BAD ARGS")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if _HAS_UI:
|
|
291
|
+
ui.step_header(step, name, args)
|
|
292
|
+
else:
|
|
293
|
+
print(f"\nstep {step} -> {name}({args})")
|
|
294
|
+
|
|
295
|
+
if not await _approve(name, args, self.cfg, client, self.force_approval):
|
|
296
|
+
result = "Denied by human reviewer. Choose a different approach."
|
|
297
|
+
else:
|
|
298
|
+
try:
|
|
299
|
+
result = await client.call_tool(name, args)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
result = f"ERROR: {name} raised: {e}"
|
|
302
|
+
|
|
303
|
+
ok = not str(result).startswith("ERROR") and result != "Denied by human reviewer. Choose a different approach."
|
|
304
|
+
if _HAS_UI:
|
|
305
|
+
ui.tool_result(step, name, str(result), ok)
|
|
306
|
+
else:
|
|
307
|
+
print(f"[step {step}] {name}({args}) -> {str(result)[:200]}")
|
|
308
|
+
self.logger.info(f"[step {step}] {name}({args}) -> {str(result)[:500]}")
|
|
309
|
+
messages.append({"role": "tool", "content": str(result)})
|
|
310
|
+
self.store.append_message(session_id, persisted, messages[-1])
|
|
311
|
+
persisted += 1
|
|
312
|
+
|
|
313
|
+
msg = "Max steps reached without completion. Check the log for progress."
|
|
314
|
+
self.logger.info(msg)
|
|
315
|
+
self.store.finish_session(session_id, "max_steps", msg)
|
|
316
|
+
return msg
|
coding_agent/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""CLI entry point (Typer).
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
python cli.py "Add type hints to utils.py and run the tests" \\
|
|
5
|
+
--project-root ./myrepo
|
|
6
|
+
|
|
7
|
+
python cli.py "Fix the failing test in test_math.py" \\
|
|
8
|
+
--project-root ./myrepo --auto-approve
|
|
9
|
+
|
|
10
|
+
python cli.py --session-name refactor-utils "Add type hints to utils.py"
|
|
11
|
+
|
|
12
|
+
python cli.py --list-sessions
|
|
13
|
+
|
|
14
|
+
python cli.py --resume refactor-utils "also add a docstring"
|
|
15
|
+
|
|
16
|
+
python cli.py --delete-session refactor-utils
|
|
17
|
+
|
|
18
|
+
python cli.py # no task -> interactive REPL, fresh session
|
|
19
|
+
python cli.py --resume refactor-utils # no task -> interactive REPL, resumed session
|
|
20
|
+
|
|
21
|
+
python cli.py --help
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from .agent import CodingAgent
|
|
30
|
+
from .config import AgentConfig
|
|
31
|
+
from .mcp_client import MCPToolClient
|
|
32
|
+
from .session_store import SessionStore
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(add_completion=False, help="Local coding agent (Ollama + Qwen Coder)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def main(
|
|
39
|
+
task: Optional[str] = typer.Argument(
|
|
40
|
+
None, help="What you want the agent to do. Optional with --resume (continues "
|
|
41
|
+
"with no new instruction) or --list-sessions.",
|
|
42
|
+
),
|
|
43
|
+
project_root: str = typer.Option(".", "--project-root", "-p", help="Sandbox directory"),
|
|
44
|
+
model: str = typer.Option("qwen3.6:35b", "--model", "-m", help="Ollama model to drive the agent"),
|
|
45
|
+
ollama_host: Optional[str] = typer.Option(
|
|
46
|
+
None, "--ollama-host", help="Ollama server URL (defaults to $OLLAMA_HOST or http://localhost:11434)",
|
|
47
|
+
),
|
|
48
|
+
ollama_api_key: Optional[str] = typer.Option(
|
|
49
|
+
None, "--ollama-api-key",
|
|
50
|
+
help="Bearer token if Ollama sits behind an authenticated proxy "
|
|
51
|
+
"(defaults to $OLLAMA_API_KEY — prefer the env var over this flag "
|
|
52
|
+
"so the key doesn't end up in your shell history).",
|
|
53
|
+
),
|
|
54
|
+
max_steps: int = typer.Option(25, "--max-steps", help="Hard cap on agent loop iterations"),
|
|
55
|
+
auto_approve: bool = typer.Option(
|
|
56
|
+
False, "--auto-approve",
|
|
57
|
+
help="Skip human approval for write/edit/shell tools. Only use in an "
|
|
58
|
+
"already-sandboxed environment. Overridden if intent parsing flags the task high-risk.",
|
|
59
|
+
),
|
|
60
|
+
log_path: str = typer.Option("agent_run.log", "--log-path", help="Where to write the structured run log"),
|
|
61
|
+
skip_intent_parsing: bool = typer.Option(
|
|
62
|
+
False, "--skip-intent-parsing",
|
|
63
|
+
help="Skip the upfront structured-intent parse and go straight into the agent loop.",
|
|
64
|
+
),
|
|
65
|
+
intent_model: Optional[str] = typer.Option(
|
|
66
|
+
None, "--intent-model", help="Smaller/faster model to use just for intent parsing (defaults to --model)",
|
|
67
|
+
),
|
|
68
|
+
db_path: str = typer.Option(
|
|
69
|
+
"agent_sessions.db", "--db-path", help="SQLite file storing session/message history",
|
|
70
|
+
),
|
|
71
|
+
resume: Optional[str] = typer.Option(
|
|
72
|
+
None, "--resume", help="Resume a previous session by id or --session-name instead of starting a new one",
|
|
73
|
+
),
|
|
74
|
+
session_name: Optional[str] = typer.Option(
|
|
75
|
+
None, "--session-name", help="Give a new session a memorable name, so you can --resume it by name later",
|
|
76
|
+
),
|
|
77
|
+
list_sessions: bool = typer.Option(
|
|
78
|
+
False, "--list-sessions", help="List saved sessions (id, name, status, task) and exit",
|
|
79
|
+
),
|
|
80
|
+
delete_session: Optional[str] = typer.Option(
|
|
81
|
+
None, "--delete-session", help="Delete a saved session (by id or --session-name) and exit",
|
|
82
|
+
),
|
|
83
|
+
):
|
|
84
|
+
"""Run the coding agent on TASK inside PROJECT_ROOT. Omit TASK to enter
|
|
85
|
+
an interactive session (fresh, or resumed with --resume)."""
|
|
86
|
+
if delete_session:
|
|
87
|
+
if SessionStore(db_path).delete_session(delete_session):
|
|
88
|
+
typer.echo(f"Deleted session {delete_session!r}.")
|
|
89
|
+
else:
|
|
90
|
+
typer.echo(f"Error: no session found with id or name {delete_session!r}.", err=True)
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
raise typer.Exit()
|
|
93
|
+
|
|
94
|
+
if list_sessions:
|
|
95
|
+
_print_sessions(SessionStore(db_path).list_sessions())
|
|
96
|
+
raise typer.Exit()
|
|
97
|
+
|
|
98
|
+
cfg = AgentConfig(
|
|
99
|
+
model=model,
|
|
100
|
+
ollama_host=ollama_host or "",
|
|
101
|
+
ollama_api_key=ollama_api_key or "",
|
|
102
|
+
project_root=project_root,
|
|
103
|
+
max_steps=max_steps,
|
|
104
|
+
auto_approve=auto_approve,
|
|
105
|
+
log_path=log_path,
|
|
106
|
+
parse_intent=not skip_intent_parsing,
|
|
107
|
+
intent_model=intent_model or "",
|
|
108
|
+
db_path=db_path,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if task is None:
|
|
112
|
+
asyncio.run(_interactive(cfg, resume, session_name))
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if resume:
|
|
116
|
+
_show_resumed_history(db_path, resume)
|
|
117
|
+
|
|
118
|
+
agent = CodingAgent(cfg)
|
|
119
|
+
try:
|
|
120
|
+
result = asyncio.run(agent.run(task, resume_session_id=resume, session_name=session_name))
|
|
121
|
+
except ValueError as e:
|
|
122
|
+
typer.echo(f"Error: {e}", err=True)
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
from . import ui
|
|
127
|
+
ui.final_result(result)
|
|
128
|
+
except ImportError:
|
|
129
|
+
typer.echo("\n=== FINAL RESULT ===")
|
|
130
|
+
typer.echo(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _interactive(cfg: AgentConfig, resume: Optional[str], session_name: Optional[str]):
|
|
134
|
+
"""REPL: keep one MCP client open across turns (avoids re-spawning the
|
|
135
|
+
tool-server subprocess every turn) and keep resuming the same session
|
|
136
|
+
(fresh on turn 1, then whatever session that turn created/resumed)."""
|
|
137
|
+
agent = CodingAgent(cfg)
|
|
138
|
+
session_id = resume
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
from . import ui
|
|
142
|
+
ui.interactive_banner(cfg.model, resumed=resume)
|
|
143
|
+
except ImportError:
|
|
144
|
+
typer.echo(f"Interactive mode (model: {cfg.model}). Type a task, /sessions to list, /exit to quit.\n")
|
|
145
|
+
|
|
146
|
+
if resume:
|
|
147
|
+
_show_resumed_history(cfg.db_path, resume)
|
|
148
|
+
|
|
149
|
+
async with MCPToolClient(cfg.project_root) as client:
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
task = _read_task()
|
|
153
|
+
except (EOFError, KeyboardInterrupt):
|
|
154
|
+
typer.echo()
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
task = task.strip()
|
|
158
|
+
if not task:
|
|
159
|
+
continue
|
|
160
|
+
if task in ("/exit", "/quit"):
|
|
161
|
+
break
|
|
162
|
+
if task == "/sessions":
|
|
163
|
+
_print_sessions(agent.store.list_sessions())
|
|
164
|
+
continue
|
|
165
|
+
if task.startswith("/delete "):
|
|
166
|
+
target = task[len("/delete "):].strip()
|
|
167
|
+
if agent.store.delete_session(target):
|
|
168
|
+
typer.echo(f"Deleted session {target!r}.")
|
|
169
|
+
if session_id is not None and agent.store.resolve_session_id(session_id) is None:
|
|
170
|
+
session_id = None # the session we were resuming just got deleted
|
|
171
|
+
else:
|
|
172
|
+
typer.echo(f"No session found with id or name {target!r}.", err=True)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
result = await agent.run(task, resume_session_id=session_id, client=client,
|
|
177
|
+
session_name=session_name, show_banner=False)
|
|
178
|
+
except ValueError as e:
|
|
179
|
+
typer.echo(f"Error: {e}", err=True)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
session_id = agent.session_id
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
from . import ui
|
|
186
|
+
ui.final_result(result)
|
|
187
|
+
except ImportError:
|
|
188
|
+
typer.echo("\n=== RESULT ===")
|
|
189
|
+
typer.echo(result)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _show_resumed_history(db_path: str, resume: str):
|
|
193
|
+
"""Print the conversation being resumed so it's visible on screen that
|
|
194
|
+
context actually carried over — agent.run() feeds it to the model
|
|
195
|
+
either way, but nothing else displays it."""
|
|
196
|
+
store = SessionStore(db_path)
|
|
197
|
+
session_id = store.resolve_session_id(resume)
|
|
198
|
+
if session_id is None:
|
|
199
|
+
return # let agent.run() raise the proper "no session found" error
|
|
200
|
+
messages = store.load_messages(session_id)
|
|
201
|
+
try:
|
|
202
|
+
from . import ui
|
|
203
|
+
ui.history_panel(messages)
|
|
204
|
+
except ImportError:
|
|
205
|
+
typer.echo(f"--- Resumed history ({len(messages)} messages) ---")
|
|
206
|
+
for m in messages:
|
|
207
|
+
if m.get("role") == "system":
|
|
208
|
+
continue
|
|
209
|
+
typer.echo(f"{m.get('role')}: {str(m.get('content'))[:200]}")
|
|
210
|
+
typer.echo("--- end history ---\n")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _read_task() -> str:
|
|
214
|
+
try:
|
|
215
|
+
from . import ui
|
|
216
|
+
return ui.prompt_task()
|
|
217
|
+
except ImportError:
|
|
218
|
+
return input("> ")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _print_sessions(sessions: list):
|
|
222
|
+
if not sessions:
|
|
223
|
+
typer.echo("No saved sessions.")
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
from . import ui
|
|
227
|
+
ui.sessions_table(sessions)
|
|
228
|
+
except ImportError:
|
|
229
|
+
for s in sessions:
|
|
230
|
+
typer.echo(f"{s['id']} {s.get('name') or '-'} [{s['status']}] {s['updated_at']} {s['task'][:70]}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
app()
|
coding_agent/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration for the coding agent."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class AgentConfig:
|
|
8
|
+
model: str = "qwen3.6:35b"
|
|
9
|
+
ollama_host: str = "" # empty = use OLLAMA_HOST env var or http://localhost:11434
|
|
10
|
+
ollama_api_key: str = "" # empty = use OLLAMA_API_KEY env var; never hardcode this
|
|
11
|
+
|
|
12
|
+
# All file/shell operations are confined to this directory. Paths that
|
|
13
|
+
# resolve outside it are rejected before any tool runs.
|
|
14
|
+
project_root: str = "."
|
|
15
|
+
|
|
16
|
+
# Tool names that execute WITHOUT asking for human approval first.
|
|
17
|
+
# Anything not listed here (write_file, edit_file, run_shell by default)
|
|
18
|
+
# will print what it's about to do and wait for confirmation, unless
|
|
19
|
+
# auto_approve=True.
|
|
20
|
+
safe_tools: tuple = ("read_file", "list_dir", "search_files", "glob_files", "git_diff")
|
|
21
|
+
|
|
22
|
+
auto_approve: bool = False # True = never prompt (use in CI with care)
|
|
23
|
+
max_steps: int = 25 # hard cap on agent loop iterations
|
|
24
|
+
|
|
25
|
+
# Parse the freeform task into structured intent (task_type, target_files,
|
|
26
|
+
# constraints, risk_level) before the agent starts acting.
|
|
27
|
+
parse_intent: bool = True
|
|
28
|
+
intent_model: str = "" # empty = reuse `model` for intent parsing too
|
|
29
|
+
max_retries: int = 3 # retries per model call on bad/malformed output
|
|
30
|
+
shell_timeout_s: int = 30
|
|
31
|
+
max_output_chars: int = 8000 # truncate tool output before feeding back to model
|
|
32
|
+
context_char_budget: int = 200_000 # rough trim threshold (chars, not tokens)
|
|
33
|
+
log_path: str = "agent_run.log"
|
|
34
|
+
db_path: str = "agent_sessions.db" # SQLite file storing session/message history
|
|
35
|
+
|
|
36
|
+
# Commands the agent is never allowed to run, regardless of approval.
|
|
37
|
+
denied_shell_patterns: tuple = field(default_factory=lambda: (
|
|
38
|
+
"rm -rf /", "rm -rf /*", ":(){ :|:& };:", "mkfs", "dd if=",
|
|
39
|
+
"> /dev/sda", "shutdown", "reboot", "sudo ", "curl | sh", "wget | sh",
|
|
40
|
+
))
|