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 +4 -0
- omni/__main__.py +6 -0
- omni/agent.py +347 -0
- omni/cli.py +393 -0
- omni/config.py +73 -0
- omni/intent.py +138 -0
- omni/mcp_client.py +459 -0
- omni/mcp_server.py +173 -0
- omni/ollama_client.py +150 -0
- omni/session_store.py +141 -0
- omni/tools.py +399 -0
- omni/ui.py +310 -0
- omni_coder-0.5.9.dist-info/METADATA +175 -0
- omni_coder-0.5.9.dist-info/RECORD +18 -0
- omni_coder-0.5.9.dist-info/WHEEL +5 -0
- omni_coder-0.5.9.dist-info/entry_points.txt +2 -0
- omni_coder-0.5.9.dist-info/licenses/LICENSE +21 -0
- omni_coder-0.5.9.dist-info/top_level.txt +1 -0
omni/__init__.py
ADDED
omni/__main__.py
ADDED
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
|