omni-coder 0.5.9__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.
omni/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Coding agent — drives Qwen Coder (or any Ollama-compatible model)
2
+ through a scoped set of file/shell tools via MCP."""
3
+
4
+ __version__ = "0.1.0"
omni/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enables `python -m omni`."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
omni/agent.py ADDED
@@ -0,0 +1,347 @@
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 os
12
+ import re
13
+ from contextlib import nullcontext
14
+
15
+ from .ollama_client import chat, OllamaError
16
+
17
+ from .config import AgentConfig
18
+ from .intent import extract_intent
19
+ from .mcp_client import MCPToolClient
20
+ from .session_store import SessionStore
21
+
22
+ try:
23
+ from . import ui
24
+ _HAS_UI = True
25
+ except ImportError:
26
+ _HAS_UI = False
27
+
28
+ SYSTEM_PROMPT = """You are a coding agent working within a defined project \
29
+ directory. You have tools to read, search, write, and edit files, check git \
30
+ diffs, and run shell commands.
31
+
32
+ Rules:
33
+ - Prefer edit_file over write_file for existing files — write_file will \
34
+ refuse to overwrite unless you pass overwrite=true.
35
+ - Before running anything destructive or irreversible, check git_diff or \
36
+ read_file first so you understand current state.
37
+ - Keep changes minimal and focused on the task.
38
+ - When you learn a durable fact about this project (a convention, gotcha, \
39
+ build quirk, or stated preference) that would help in a future session, \
40
+ call save_memory to persist it. Keep notes short and skip anything already \
41
+ obvious from the code itself.
42
+ - When the task is fully done, reply with plain text (no tool call) \
43
+ summarizing what changed and how to verify it (e.g. which command to run).
44
+ """
45
+
46
+
47
+ def _load_project_memory(project_root: str, memory_path: str) -> str:
48
+ """Read back whatever save_memory has accumulated for this project, so
49
+ it can be folded into the system prompt at the start of a new session."""
50
+ path = os.path.join(project_root, memory_path)
51
+ if not os.path.isfile(path):
52
+ return ""
53
+ with open(path, "r", encoding="utf-8") as f:
54
+ return f.read().strip()
55
+
56
+
57
+ def _setup_logger(log_path: str) -> logging.Logger:
58
+ logger = logging.getLogger("omni")
59
+ logger.setLevel(logging.INFO)
60
+ logger.handlers.clear()
61
+ fh = logging.FileHandler(log_path, encoding="utf-8")
62
+ fh.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
63
+ logger.addHandler(fh)
64
+ return logger
65
+
66
+
67
+ async def _approve(tool_name: str, args: dict, cfg: AgentConfig, client: MCPToolClient, force_approval: bool = False) -> bool:
68
+ if tool_name in cfg.safe_tools:
69
+ return True
70
+ if cfg.auto_approve and not force_approval:
71
+ return True
72
+ if _HAS_UI:
73
+ return await ui.request_approval(tool_name, args, client)
74
+ print(f"\n--- Approval needed: {tool_name} ---")
75
+ print(json.dumps(args, indent=2)[:2000])
76
+ answer = input("Proceed? [y/N] ").strip().lower()
77
+ return answer == "y"
78
+
79
+
80
+ def _find_json_objects(text: str) -> list:
81
+ """Scan text for top-level {...} objects, tracking string-literal state
82
+ so braces inside quoted content (e.g. code the model is trying to write)
83
+ don't throw off the balance count."""
84
+ objs = []
85
+ i, n = 0, len(text)
86
+ while i < n:
87
+ if text[i] == "{":
88
+ depth, in_str, esc, j = 0, False, False, i
89
+ while j < n:
90
+ c = text[j]
91
+ if in_str:
92
+ if esc:
93
+ esc = False
94
+ elif c == "\\":
95
+ esc = True
96
+ elif c == '"':
97
+ in_str = False
98
+ elif c == '"':
99
+ in_str = True
100
+ elif c == "{":
101
+ depth += 1
102
+ elif c == "}":
103
+ depth -= 1
104
+ if depth == 0:
105
+ objs.append(text[i:j + 1])
106
+ break
107
+ j += 1
108
+ i = j + 1
109
+ else:
110
+ i += 1
111
+ return objs
112
+
113
+
114
+ def _recover_text_tool_calls(content: str, tool_names: set) -> list:
115
+ """Some models print a tool call as plain-text JSON (`{"name": ...,
116
+ "arguments": {...}}`) instead of using the tool-calling API, which would
117
+ otherwise look like a final answer and silently end the run without the
118
+ tool ever executing. Recover any such calls from `content`."""
119
+ if not content or "{" not in content:
120
+ return []
121
+ calls = []
122
+ for raw in _find_json_objects(content):
123
+ try:
124
+ obj = json.loads(raw)
125
+ except json.JSONDecodeError:
126
+ continue
127
+ name, args = obj.get("name"), obj.get("arguments")
128
+ if name in tool_names and isinstance(args, dict):
129
+ calls.append({
130
+ "id": f"fallback_{len(calls)}",
131
+ "type": "function",
132
+ "function": {
133
+ "name": name,
134
+ "arguments": json.dumps(args, ensure_ascii=False),
135
+ },
136
+ })
137
+ return calls
138
+
139
+
140
+ def _trim_history(messages: list, budget: int) -> list:
141
+ """Keep the system + user task message plus the most recent turns
142
+ within a rough character budget. Crude but effective without pulling
143
+ in a tokenizer dependency."""
144
+ total = sum(len(str(m.get("content", ""))) for m in messages)
145
+ if total <= budget:
146
+ return messages
147
+ head, tail = messages[:2], messages[2:]
148
+ while tail and total > budget:
149
+ removed = tail.pop(0)
150
+ total -= len(str(removed.get("content", "")))
151
+ return head + tail
152
+
153
+
154
+ class CodingAgent:
155
+ def __init__(self, cfg: AgentConfig):
156
+ self.cfg = cfg
157
+ self.logger = _setup_logger(cfg.log_path)
158
+ self.force_approval = False # set True for the run if intent is high-risk
159
+ self.store = SessionStore(cfg.db_path)
160
+ self.session_id = None # set by run() to whichever session the last turn used
161
+
162
+ async def _call_model(self, messages: list, tool_schemas: list):
163
+ """Call Ollama with retries for transient errors (connection refused,
164
+ 5xx, malformed tool-call output — small local models occasionally
165
+ emit broken JSON)."""
166
+ last_err = None
167
+ spinner = ui.thinking() if _HAS_UI else nullcontext()
168
+ with spinner:
169
+ for attempt in range(1, self.cfg.max_retries + 1):
170
+ try:
171
+ return await chat(model=self.cfg.model, messages=messages, tools=tool_schemas,
172
+ base_url=self.cfg.ollama_host, api_key=self.cfg.ollama_api_key)
173
+ except OllamaError as e:
174
+ last_err = e
175
+ self.logger.info(f"model call failed (attempt {attempt}): {e}")
176
+ if _HAS_UI:
177
+ spinner.update(f"[bold yellow]Thinking… (retry {attempt}/{self.cfg.max_retries})[/bold yellow]")
178
+ await asyncio.sleep(min(2 ** attempt, 10))
179
+ except Exception as e:
180
+ last_err = e
181
+ self.logger.info(f"unexpected error (attempt {attempt}): {e}")
182
+ if _HAS_UI:
183
+ spinner.update(f"[bold yellow]Thinking… (retry {attempt}/{self.cfg.max_retries})[/bold yellow]")
184
+ await asyncio.sleep(1)
185
+ raise RuntimeError(f"Model call failed after {self.cfg.max_retries} attempts: {last_err}")
186
+
187
+ async def run(self, task: str = "", resume_session_id: str = None, client: MCPToolClient = None,
188
+ session_name: str = None, show_banner: bool = True) -> str:
189
+ """Run one turn of the agent loop. If `client` is given (an already
190
+ -open MCPToolClient), it's reused instead of spawning a fresh MCP
191
+ server subprocess — used by the interactive REPL so each turn
192
+ doesn't pay subprocess-startup cost. `self.session_id` is set to
193
+ whichever session this turn ran against, so callers (e.g. the REPL)
194
+ can pass it back in as `resume_session_id` on the next turn.
195
+ `resume_session_id` accepts either a session id or a --session-name.
196
+ `session_name` optionally names a newly-created session. Pass
197
+ `show_banner=False` when a caller (e.g. the REPL) already prints its
198
+ own header and doesn't want one repeated every turn."""
199
+ resuming = resume_session_id is not None
200
+
201
+ if resuming:
202
+ session_id = self.store.resolve_session_id(resume_session_id)
203
+ if session_id is None:
204
+ raise ValueError(f"No session found with id or name {resume_session_id!r}")
205
+ messages = self.store.load_messages(session_id)
206
+ persisted = len(messages) # already in the DB, don't re-write these
207
+ label = task or "[continuing previous task]"
208
+ if _HAS_UI and show_banner:
209
+ ui.banner(f"(resumed {session_id}) {label}", self.cfg.model)
210
+ self.logger.info(f"RESUME session={session_id} TASK: {label}")
211
+ if task:
212
+ messages.append({"role": "user", "content": task})
213
+ else:
214
+ if _HAS_UI and show_banner:
215
+ ui.banner(task, self.cfg.model)
216
+ session_id = self.store.create_session(self.cfg.project_root, self.cfg.model, task, name=session_name)
217
+ system_content = SYSTEM_PROMPT
218
+ memory_text = _load_project_memory(self.cfg.project_root, self.cfg.memory_path)
219
+ if memory_text:
220
+ system_content += "\n\n# Project memory (persisted from previous sessions)\n" + memory_text
221
+ messages = [
222
+ {"role": "system", "content": system_content},
223
+ {"role": "user", "content": task},
224
+ ]
225
+ persisted = 0
226
+ self.logger.info(f"TASK: {task} (session={session_id})")
227
+
228
+ self.session_id = session_id
229
+
230
+ try:
231
+ if client is not None:
232
+ return await self._run_loop(task, session_id, messages, persisted, resuming, client)
233
+ async with MCPToolClient(self.cfg.project_root, mcp_config_path=self.cfg.mcp_config_path or None,
234
+ extra_servers=self.cfg.mcp_servers or None,
235
+ embedding_model=self.cfg.embedding_model or None,
236
+ ollama_host=self.cfg.ollama_host or None,
237
+ ollama_api_key=self.cfg.ollama_api_key or None) as owned_client:
238
+ return await self._run_loop(task, session_id, messages, persisted, resuming, owned_client)
239
+ except Exception as e:
240
+ self.store.finish_session(session_id, "error", str(e))
241
+ raise
242
+
243
+ async def _run_loop(self, task: str, session_id: str, messages: list, persisted: int,
244
+ resuming: bool, client: MCPToolClient) -> str:
245
+ tool_schemas = await client.list_llm_tools()
246
+ tool_names = {t["function"]["name"] for t in tool_schemas}
247
+
248
+ if not resuming and self.cfg.parse_intent:
249
+ intent_model = self.cfg.intent_model or self.cfg.model
250
+ spinner = ui.thinking("Parsing intent…") if _HAS_UI else nullcontext()
251
+ with spinner:
252
+ intent = await extract_intent(task, intent_model, self.cfg.max_retries, self.logger,
253
+ base_url=self.cfg.ollama_host, api_key=self.cfg.ollama_api_key)
254
+
255
+ existing = {f: await client.file_exists(f) for f in intent.target_files}
256
+ context_block = intent.as_context_block(existing)
257
+ messages.insert(1, {"role": "system", "content": context_block})
258
+
259
+ if _HAS_UI:
260
+ ui.intent_panel(intent, existing)
261
+ else:
262
+ print(f"\n{context_block}\n")
263
+
264
+ if intent.risk_level == "high":
265
+ self.force_approval = True
266
+ warning = "High-risk intent detected — approval required for all write/shell actions this run, even with --auto-approve."
267
+ if _HAS_UI:
268
+ ui.high_risk_warning()
269
+ else:
270
+ print(f"⚠️ {warning}")
271
+ self.logger.info(warning)
272
+
273
+ for m in messages[persisted:]:
274
+ self.store.append_message(session_id, persisted, m)
275
+ persisted += 1
276
+
277
+ for step in range(1, self.cfg.max_steps + 1):
278
+ messages = _trim_history(messages, self.cfg.context_char_budget)
279
+ msg = await self._call_model(messages, tool_schemas)
280
+
281
+ tool_calls = msg.get("tool_calls")
282
+ if not tool_calls:
283
+ recovered = _recover_text_tool_calls(msg.get("content", ""), tool_names)
284
+ if recovered:
285
+ msg["tool_calls"] = recovered
286
+ tool_calls = recovered
287
+ self.logger.info(
288
+ f"[step {step}] model printed tool call as plain text; "
289
+ f"recovered {len(recovered)} call(s) via fallback parsing"
290
+ )
291
+
292
+ messages.append(msg)
293
+ self.store.append_message(session_id, persisted, msg)
294
+ persisted += 1
295
+
296
+ if not tool_calls:
297
+ final = msg.get("content", "")
298
+ self.logger.info(f"DONE: {final}")
299
+ self.store.finish_session(session_id, "done", final)
300
+ return final
301
+
302
+ for call in tool_calls:
303
+ name = call["function"]["name"]
304
+ args = call["function"]["arguments"]
305
+ if isinstance(args, str):
306
+ try:
307
+ args = json.loads(args)
308
+ except json.JSONDecodeError:
309
+ result = f"ERROR: model sent malformed arguments: {args!r}"
310
+ messages.append({"role": "tool", "content": result})
311
+ self.store.append_message(session_id, persisted, messages[-1])
312
+ persisted += 1
313
+ self.logger.info(f"[step {step}] {name} -> BAD ARGS")
314
+ continue
315
+
316
+ if _HAS_UI:
317
+ ui.step_header(step, name, args)
318
+ else:
319
+ print(f"\nstep {step} -> {name}({args})")
320
+
321
+ if not await _approve(name, args, self.cfg, client, self.force_approval):
322
+ result = "Denied by human reviewer. Choose a different approach."
323
+ else:
324
+ try:
325
+ result = await client.call_tool(name, args)
326
+ if name == "search_tools" and not str(result).startswith("ERROR"):
327
+ # Deferred-loading MCP tools just got revealed — refresh
328
+ # the schemas handed to the model so it can call them.
329
+ tool_schemas = await client.list_llm_tools()
330
+ tool_names = {t["function"]["name"] for t in tool_schemas}
331
+ except Exception as e:
332
+ result = f"ERROR: {name} raised: {e}"
333
+
334
+ ok = not str(result).startswith("ERROR") and result != "Denied by human reviewer. Choose a different approach."
335
+ if _HAS_UI:
336
+ ui.tool_result(step, name, str(result), ok)
337
+ else:
338
+ print(f"[step {step}] {name}({args}) -> {str(result)[:200]}")
339
+ self.logger.info(f"[step {step}] {name}({args}) -> {str(result)[:500]}")
340
+ messages.append({"role": "tool", "content": str(result)})
341
+ self.store.append_message(session_id, persisted, messages[-1])
342
+ persisted += 1
343
+
344
+ msg = "Max steps reached without completion. Check the log for progress."
345
+ self.logger.info(msg)
346
+ self.store.finish_session(session_id, "max_steps", msg)
347
+ return msg