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.
@@ -0,0 +1,4 @@
1
+ """Local coding agent — drives Qwen Coder (or any Ollama-compatible model)
2
+ through a sandboxed set of file/shell tools via MCP."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enables `python -m coding_agent`."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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
+ ))