omega-code 0.4.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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/tools.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import difflib
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil as _shutil
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, BinaryIO, cast
|
|
17
|
+
|
|
18
|
+
from . import events, hooks, mcp, permissions
|
|
19
|
+
from .config import HookRule
|
|
20
|
+
from .events import Option
|
|
21
|
+
from .llm import ToolCall
|
|
22
|
+
|
|
23
|
+
ToolArgs = dict[str, Any]
|
|
24
|
+
ToolFn = Callable[..., Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ToolEntry:
|
|
29
|
+
fn: ToolFn
|
|
30
|
+
locks_path: str | None
|
|
31
|
+
mutates: bool
|
|
32
|
+
deferred: bool
|
|
33
|
+
schema: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MAX_OUTPUT = 30000
|
|
37
|
+
REGISTRY: dict[str, ToolEntry] = {}
|
|
38
|
+
_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
|
39
|
+
_OFFLOAD_RE = re.compile(r"saved as artifact ([0-9a-f]{8})")
|
|
40
|
+
|
|
41
|
+
# Hard cap on any string that re-enters the model's context through tools.run
|
|
42
|
+
# (a plain result, an offload preview, or a fetch_result page) -- independent
|
|
43
|
+
# of whether it was offloaded, so a huge `limit` on fetch_result can't defeat
|
|
44
|
+
# it either.
|
|
45
|
+
MAX_INLINE_CHARS = 24_000
|
|
46
|
+
|
|
47
|
+
# Total chars written to artifacts this turn (a "turn" = one run_turn call,
|
|
48
|
+
# spanning every round and every subagent dispatched within it). Once this is
|
|
49
|
+
# exceeded, further large results skip offload entirely -- an artifact this
|
|
50
|
+
# session can no longer read back in full is worse than a longer inline
|
|
51
|
+
# truncation.
|
|
52
|
+
TURN_RESULT_BUDGET_CHARS = 2_000_000
|
|
53
|
+
_turn_chars_stored = 0
|
|
54
|
+
|
|
55
|
+
# Set by the CLI. None means no prompting (scripted / --yolo).
|
|
56
|
+
CONFIRM: Callable[[str, ToolArgs, str], Awaitable[bool]] | None = None
|
|
57
|
+
_confirm_lock = asyncio.Lock()
|
|
58
|
+
TAINTED = False
|
|
59
|
+
|
|
60
|
+
# Set by the CLI right after the session is created/loaded. None means no
|
|
61
|
+
# session context (e.g. subagent-only test contexts) -- offload is skipped.
|
|
62
|
+
SESSION_ID: str | None = None
|
|
63
|
+
|
|
64
|
+
# Set by the CLI. None means no interactive session (scripted / --yolo).
|
|
65
|
+
ASK_USER: Callable[[str, list[Option], bool], Awaitable[str]] | None = None
|
|
66
|
+
|
|
67
|
+
# Set by run_turn from cfg.hooks. Empty dict means no hooks configured.
|
|
68
|
+
HOOK_RULES: dict[str, list[HookRule]] = {}
|
|
69
|
+
|
|
70
|
+
# Set by run_turn, mirroring subagent.EMIT -- lets background-job lifecycle
|
|
71
|
+
# events reach the UI without threading an emit callback through every tool.
|
|
72
|
+
EMIT: Callable[[events.Event], None] | None = None
|
|
73
|
+
|
|
74
|
+
# ~/.omega/sessions -- a session's background-job logs live under
|
|
75
|
+
# <JOBS_DIR>/<session id>/jobs/<job id>.log. A separate module attribute (not
|
|
76
|
+
# shared with session.DIR/artifacts.DIR) so tests can monkeypatch it in isolation.
|
|
77
|
+
JOBS_DIR = Path.home() / ".omega" / "sessions"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Job:
|
|
82
|
+
id: str
|
|
83
|
+
command: str
|
|
84
|
+
proc: subprocess.Popen[bytes]
|
|
85
|
+
log_path: Path
|
|
86
|
+
started: float
|
|
87
|
+
finished: bool = False
|
|
88
|
+
exit_code: int | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_JOBS: dict[str, Job] = {}
|
|
92
|
+
_JOBS_LOCK = threading.Lock()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_tainted(value: bool) -> None:
|
|
96
|
+
global TAINTED
|
|
97
|
+
TAINTED = value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def reset_turn_budget() -> None:
|
|
101
|
+
"""Called once at the start of every `run_turn` -- the budget is per
|
|
102
|
+
user-visible turn, shared across every round and subagent within it."""
|
|
103
|
+
global _turn_chars_stored
|
|
104
|
+
_turn_chars_stored = 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cap_inline(text: str) -> str:
|
|
108
|
+
if len(text) <= MAX_INLINE_CHARS:
|
|
109
|
+
return text
|
|
110
|
+
marker = (f"\n[inline result capped at {MAX_INLINE_CHARS} chars; narrow "
|
|
111
|
+
f"the query or page with offset/limit for more]")
|
|
112
|
+
return text[:MAX_INLINE_CHARS - len(marker)] + marker
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def offload_info(result: str) -> tuple[bool, str | None]:
|
|
116
|
+
m = _OFFLOAD_RE.search(result)
|
|
117
|
+
return (bool(m), m.group(1) if m else None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_error_result(text: str) -> bool:
|
|
121
|
+
"""Shared with loop.py's repeat-fail guard: a result counts as an error
|
|
122
|
+
either because run() itself prefixed it with "error: " (a raised
|
|
123
|
+
exception, a permissions denial, an MCP isError result -- see mcp.py's
|
|
124
|
+
call()), or because a remote tool's own untrusted payload carries an
|
|
125
|
+
"error" field even without that prefix."""
|
|
126
|
+
if text.startswith("error:"):
|
|
127
|
+
return True
|
|
128
|
+
return "<untrusted" in text and '"error"' in text
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def truncate(text: str, limit: int = MAX_OUTPUT) -> str:
|
|
132
|
+
"""Head+tail. Head-only loses the end of failing output, which is where the
|
|
133
|
+
error almost always is."""
|
|
134
|
+
if len(text) <= limit:
|
|
135
|
+
return text
|
|
136
|
+
head, tail = int(limit * 0.6), int(limit * 0.4)
|
|
137
|
+
dropped = len(text) - head - tail
|
|
138
|
+
return (f"{text[:head]}\n"
|
|
139
|
+
f"... [truncated {dropped} chars from the middle] ...\n{text[-tail:]}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def tool(name: str, description: str, params: dict[str, Any], required: list[str],
|
|
143
|
+
locks_path: str | None = None, mutates: bool = False,
|
|
144
|
+
deferred: bool = False) -> Callable[[ToolFn], ToolFn]:
|
|
145
|
+
def wrap(fn: ToolFn) -> ToolFn:
|
|
146
|
+
REGISTRY[name] = ToolEntry(
|
|
147
|
+
fn=fn, locks_path=locks_path, mutates=mutates, deferred=deferred,
|
|
148
|
+
schema={
|
|
149
|
+
"type": "function",
|
|
150
|
+
"function": {
|
|
151
|
+
"name": name,
|
|
152
|
+
"description": description,
|
|
153
|
+
"parameters": {"type": "object", "properties": params,
|
|
154
|
+
"required": required},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return fn
|
|
159
|
+
return wrap
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
READ_ONLY = {"read", "grep", "glob", "recall", "subagent", "find_tools", "call_tool",
|
|
163
|
+
"fetch_result", "list_artifacts", "ask_user", "skill", "bash_status"}
|
|
164
|
+
|
|
165
|
+
# Tiny confirmations, or themselves the retrieval path -- offloading
|
|
166
|
+
# fetch_result's own output would be an infinite regress.
|
|
167
|
+
_NO_OFFLOAD = {"write", "edit", "remember", "supersede", "link", "ask_user",
|
|
168
|
+
"save_artifact", "update_artifact", "fetch_result", "list_artifacts"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def schemas(names: set[str] | None = None) -> list[dict[str, Any]]:
|
|
172
|
+
"""Deferred tools stay out of the prefix: they are reachable via
|
|
173
|
+
find_tools/call_tool instead of costing tokens on every request."""
|
|
174
|
+
if names is None:
|
|
175
|
+
return [t.schema for t in REGISTRY.values() if not t.deferred]
|
|
176
|
+
return [t.schema for n, t in REGISTRY.items() if n in names and not t.deferred]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def deferred() -> dict[str, ToolEntry]:
|
|
180
|
+
return {n: t for n, t in REGISTRY.items() if t.deferred}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
S = {"type": "string"}
|
|
184
|
+
I = {"type": "integer"}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@tool("read", "Read a file. Returns numbered lines.",
|
|
188
|
+
{"path": S, "offset": I, "limit": I}, ["path"])
|
|
189
|
+
def _read(path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
190
|
+
# Files on this machine are the user's own; only remote (MCP) content
|
|
191
|
+
# taints the turn. Tainting on any read outside cwd meant every worktree
|
|
192
|
+
# edit session prompted for bash on every turn.
|
|
193
|
+
p = Path(path).expanduser()
|
|
194
|
+
lines = p.read_text(errors="replace").splitlines()
|
|
195
|
+
chosen = lines[offset:offset + limit]
|
|
196
|
+
width = len(str(offset + len(chosen)))
|
|
197
|
+
return truncate("\n".join(f"{i + offset + 1:>{width}}\t{l}"
|
|
198
|
+
for i, l in enumerate(chosen)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@tool("write", "Write a file, creating parent directories.",
|
|
202
|
+
{"path": S, "content": S}, ["path", "content"], locks_path="path", mutates=True)
|
|
203
|
+
def _write(path: str, content: str) -> str:
|
|
204
|
+
p = Path(path).expanduser()
|
|
205
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
p.write_text(content)
|
|
207
|
+
return f"wrote {len(content.splitlines())} lines ({len(content)} chars) to {p}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@tool("edit", "Replace an exact unique string in a file.",
|
|
211
|
+
{"path": S, "old": S, "new": S}, ["path", "old", "new"], locks_path="path", mutates=True)
|
|
212
|
+
def _edit(path: str, old: str, new: str) -> str:
|
|
213
|
+
p = Path(path).expanduser()
|
|
214
|
+
text = p.read_text()
|
|
215
|
+
n = text.count(old)
|
|
216
|
+
if n == 0:
|
|
217
|
+
raise ValueError("string not found")
|
|
218
|
+
if n > 1:
|
|
219
|
+
raise ValueError(f"string appears {n} times; make it unique")
|
|
220
|
+
updated = text.replace(old, new)
|
|
221
|
+
p.write_text(updated)
|
|
222
|
+
return f"edited {p}\n{_diff(text, updated, str(p))}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# Enough to show an ordinary edit whole, bounded so a large mechanical
|
|
226
|
+
# replacement cannot push the rest of the turn out of context.
|
|
227
|
+
_DIFF_LINES = 40
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _diff(before: str, after: str, path: str) -> str:
|
|
231
|
+
"""A unified diff of what the edit actually changed.
|
|
232
|
+
|
|
233
|
+
Returned to the model, not just drawn for the human: `edited <path>` gave
|
|
234
|
+
it no way to tell a correct replacement from one that matched in an
|
|
235
|
+
unintended place, so it re-read the file after nearly every edit. The
|
|
236
|
+
diff answers that in a fraction of the tokens the re-read cost."""
|
|
237
|
+
lines = list(difflib.unified_diff(before.splitlines(), after.splitlines(),
|
|
238
|
+
fromfile=path, tofile=path, lineterm="", n=2))
|
|
239
|
+
body = lines[2:] # the ---/+++ header repeats the path already on the row
|
|
240
|
+
if len(body) > _DIFF_LINES:
|
|
241
|
+
body = body[:_DIFF_LINES] + [f"... [{len(body) - _DIFF_LINES} more diff lines]"]
|
|
242
|
+
return "\n".join(body)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@tool("bash", "Run a shell command. Returns combined stdout+stderr. Pass "
|
|
246
|
+
"background=true for a long-running command (a server, a watch loop) -- it "
|
|
247
|
+
"starts the process and returns immediately with a job id; poll it with "
|
|
248
|
+
"bash_status(id).",
|
|
249
|
+
{"command": S, "timeout": I, "background": {"type": "boolean"}}, ["command"], mutates=True)
|
|
250
|
+
def _bash(command: str, timeout: int | None = 120, background: bool = False) -> str:
|
|
251
|
+
if background:
|
|
252
|
+
return _bash_background(command)
|
|
253
|
+
# start_new_session puts the child in its own process group, so a timeout
|
|
254
|
+
# can kill the whole tree; subprocess.run would only kill the shell and
|
|
255
|
+
# leave its children running forever.
|
|
256
|
+
# `timeout or 120` would treat an explicit 0 as "not supplied".
|
|
257
|
+
timeout = max(1, min(int(120 if timeout is None else timeout), 600))
|
|
258
|
+
p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE,
|
|
259
|
+
stderr=subprocess.PIPE, text=True, cwd=os.getcwd(),
|
|
260
|
+
start_new_session=True)
|
|
261
|
+
try:
|
|
262
|
+
stdout, stderr = p.communicate(timeout=timeout)
|
|
263
|
+
code = p.returncode
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
# start_new_session makes the child its own group leader, so pgid == pid.
|
|
266
|
+
# Look it up via getpgid instead and it fails once the shell has exited,
|
|
267
|
+
# which is exactly when backgrounded children are still alive.
|
|
268
|
+
_kill_group(p.pid)
|
|
269
|
+
for cleanup in (p.kill, p.wait):
|
|
270
|
+
try:
|
|
271
|
+
cleanup()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
return truncate(f"(timed out after {timeout}s; process group killed)")
|
|
275
|
+
|
|
276
|
+
parts: list[str] = []
|
|
277
|
+
if (stdout or "").strip():
|
|
278
|
+
parts.append(truncate(stdout.strip(), int(MAX_OUTPUT * 0.75)))
|
|
279
|
+
if (stderr or "").strip():
|
|
280
|
+
# own budget: stderr is small and carries the failure reason
|
|
281
|
+
parts.append("[stderr]\n" + truncate(stderr.strip(), int(MAX_OUTPUT * 0.25)))
|
|
282
|
+
if code != 0:
|
|
283
|
+
parts.append(f"[exit {code}]")
|
|
284
|
+
return "\n".join(parts) or "(no output, exit 0)"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _kill_group(pgid: int) -> None:
|
|
288
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
289
|
+
try:
|
|
290
|
+
os.killpg(pgid, sig)
|
|
291
|
+
except (ProcessLookupError, PermissionError):
|
|
292
|
+
return
|
|
293
|
+
for _ in range(6):
|
|
294
|
+
try:
|
|
295
|
+
os.killpg(pgid, 0)
|
|
296
|
+
except (ProcessLookupError, PermissionError):
|
|
297
|
+
return
|
|
298
|
+
time.sleep(0.25)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _jobs_dir() -> Path:
|
|
302
|
+
d = JOBS_DIR / (SESSION_ID or "no-session") / "jobs"
|
|
303
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
return d
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _bash_background(command: str) -> str:
|
|
308
|
+
job_id = secrets.token_hex(3)
|
|
309
|
+
log_path = _jobs_dir() / f"{job_id}.log"
|
|
310
|
+
log_file = log_path.open("wb")
|
|
311
|
+
try:
|
|
312
|
+
proc = subprocess.Popen(command, shell=True, stdout=log_file, stderr=subprocess.STDOUT,
|
|
313
|
+
cwd=os.getcwd(), start_new_session=True)
|
|
314
|
+
except OSError as e:
|
|
315
|
+
log_file.close()
|
|
316
|
+
return f"error: could not start background job: {e}"
|
|
317
|
+
job = Job(id=job_id, command=command, proc=proc, log_path=log_path, started=time.time())
|
|
318
|
+
with _JOBS_LOCK:
|
|
319
|
+
_JOBS[job_id] = job
|
|
320
|
+
if EMIT:
|
|
321
|
+
EMIT(events.JobStarted(id=job_id, command=command))
|
|
322
|
+
threading.Thread(target=_watch_job, args=(job, log_file), daemon=True).start()
|
|
323
|
+
return f"started background job {job_id} (pid {proc.pid}) -- check with bash_status({job_id!r})"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _watch_job(job: Job, log_file: BinaryIO) -> None:
|
|
327
|
+
code = job.proc.wait()
|
|
328
|
+
try:
|
|
329
|
+
log_file.close()
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
with _JOBS_LOCK:
|
|
333
|
+
job.finished = True
|
|
334
|
+
job.exit_code = code
|
|
335
|
+
if EMIT:
|
|
336
|
+
EMIT(events.JobFinished(id=job.id, exit_code=code))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _tail_log(path: Path, lines: int = 40) -> str:
|
|
340
|
+
try:
|
|
341
|
+
text = path.read_text(errors="replace")
|
|
342
|
+
except OSError:
|
|
343
|
+
return "(no output yet)"
|
|
344
|
+
return "\n".join(text.splitlines()[-lines:]) or "(no output yet)"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def list_jobs() -> list[dict[str, Any]]:
|
|
348
|
+
"""Background jobs started this process, running or finished -- used to
|
|
349
|
+
tell the user what's still going at session end (they are left running,
|
|
350
|
+
not killed: they're the user's own processes)."""
|
|
351
|
+
with _JOBS_LOCK:
|
|
352
|
+
return [{"id": j.id, "command": j.command, "finished": j.finished,
|
|
353
|
+
"exit_code": j.exit_code, "pid": j.proc.pid} for j in _JOBS.values()]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@tool("bash_status", "Check a background job started with bash(..., background=True): "
|
|
357
|
+
"running/finished state, exit code, and a tail of its output.",
|
|
358
|
+
{"id": S}, ["id"])
|
|
359
|
+
def _bash_status(id: str) -> str:
|
|
360
|
+
with _JOBS_LOCK:
|
|
361
|
+
job = _JOBS.get(id)
|
|
362
|
+
if job is None:
|
|
363
|
+
return f"error: no background job {id!r}"
|
|
364
|
+
tail = _tail_log(job.log_path)
|
|
365
|
+
if job.finished:
|
|
366
|
+
return f"job {id}: finished, exit {job.exit_code}\n{tail}"
|
|
367
|
+
return f"job {id}: running (pid {job.proc.pid})\n{tail}"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@tool("grep", "Search file contents by regex. Returns path:line:text.",
|
|
371
|
+
{"pattern": S, "path": S, "glob": S}, ["pattern"])
|
|
372
|
+
def _grep(pattern: str, path: str = ".", glob: str = "*") -> str:
|
|
373
|
+
if not _shutil.which("rg"):
|
|
374
|
+
raise RuntimeError("ripgrep (rg) not installed; use bash with grep instead")
|
|
375
|
+
cmd = ["rg", "--line-number", "--no-heading", "--color=never",
|
|
376
|
+
"--glob", glob, "-e", pattern, path]
|
|
377
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
378
|
+
if r.returncode >= 2:
|
|
379
|
+
return f"error: ripgrep failed: {(r.stderr or '').strip()[:300]}"
|
|
380
|
+
return truncate(r.stdout.strip() or "(no matches)", 12000)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@tool("glob", "Find files by name pattern, newest first.",
|
|
384
|
+
{"pattern": S, "path": S}, ["pattern"])
|
|
385
|
+
def _glob(pattern: str, path: str = ".") -> str:
|
|
386
|
+
root = Path(path).expanduser()
|
|
387
|
+
# pathlib.glob understands "**" as spanning directories; fnmatch does not.
|
|
388
|
+
matches = root.glob(pattern) if "/" in pattern else root.rglob(pattern)
|
|
389
|
+
skip = {".git", "node_modules", "__pycache__", ".venv"}
|
|
390
|
+
hits = [f for f in matches if f.is_file() and not skip & set(f.parts)]
|
|
391
|
+
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
392
|
+
return truncate("\n".join(str(p) for p in hits[:200]) or "(no matches)")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def run(call: ToolCall, allowed: set[str] | None = None) -> str:
|
|
396
|
+
"""`allowed` is enforced here, not merely omitted from the schema list.
|
|
397
|
+
Filtering schemas only hides a tool; a model can still emit the call."""
|
|
398
|
+
if allowed is not None and call.name not in allowed:
|
|
399
|
+
return (f"error: tool {call.name!r} is not permitted in this mode "
|
|
400
|
+
f"(allowed: {', '.join(sorted(allowed))})")
|
|
401
|
+
entry = REGISTRY.get(call.name)
|
|
402
|
+
if entry is None:
|
|
403
|
+
return f"error: unknown tool {call.name!r}"
|
|
404
|
+
try:
|
|
405
|
+
args = call.args()
|
|
406
|
+
|
|
407
|
+
verdict, why = permissions.decide(call.name, args, tainted=TAINTED)
|
|
408
|
+
if verdict == permissions.DENY:
|
|
409
|
+
return (f"error: refused -- {why}. This is a hard limit; do not "
|
|
410
|
+
f"retry it or work around it. Tell the user instead.")
|
|
411
|
+
if verdict == permissions.ASK:
|
|
412
|
+
if CONFIRM is None:
|
|
413
|
+
return (f"error: {call.name} requires confirmation ({why}) but "
|
|
414
|
+
f"omega is running non-interactively. Re-run without --yolo "
|
|
415
|
+
f"in a terminal, or narrow the command.")
|
|
416
|
+
# Serialized: parallel dispatch would interleave prompts. Every
|
|
417
|
+
# waiter re-decides once it holds the lock -- a sibling call that
|
|
418
|
+
# was ASK when this one was dispatched may have just answered
|
|
419
|
+
# "always" and stored an ALLOW rule (permissions.remember), in
|
|
420
|
+
# which case prompting again for the identical rule is pure noise.
|
|
421
|
+
async with _confirm_lock:
|
|
422
|
+
verdict, why = permissions.decide(call.name, args, tainted=TAINTED)
|
|
423
|
+
if verdict == permissions.DENY:
|
|
424
|
+
return (f"error: refused -- {why}. This is a hard limit; do not "
|
|
425
|
+
f"retry it or work around it. Tell the user instead.")
|
|
426
|
+
ok = True if verdict == permissions.ALLOW else await CONFIRM(call.name, args, why)
|
|
427
|
+
if not ok:
|
|
428
|
+
return "error: denied by user"
|
|
429
|
+
|
|
430
|
+
cwd = os.getcwd()
|
|
431
|
+
pre_rules = HOOK_RULES.get("pre_tool", [])
|
|
432
|
+
if pre_rules:
|
|
433
|
+
blocked, why = await asyncio.to_thread(hooks.run_pre, pre_rules, call.name, args, cwd)
|
|
434
|
+
if blocked:
|
|
435
|
+
return f"error: blocked by hook: {why}"
|
|
436
|
+
|
|
437
|
+
fn = entry.fn
|
|
438
|
+
call_it = ((lambda: fn(**args)) if inspect.iscoroutinefunction(fn)
|
|
439
|
+
else (lambda: asyncio.to_thread(fn, **args)))
|
|
440
|
+
lock_key = args.get(entry.locks_path) if entry.locks_path else None
|
|
441
|
+
if lock_key:
|
|
442
|
+
async with _locks[str(Path(lock_key).expanduser().resolve())]:
|
|
443
|
+
result = await call_it()
|
|
444
|
+
else:
|
|
445
|
+
result = await call_it()
|
|
446
|
+
except Exception as e:
|
|
447
|
+
return f"error: {type(e).__name__}: {e}"
|
|
448
|
+
|
|
449
|
+
result = cast(str, result)
|
|
450
|
+
post_rules = HOOK_RULES.get("post_tool", [])
|
|
451
|
+
if post_rules:
|
|
452
|
+
appended = await asyncio.to_thread(hooks.run_post, post_rules, call.name, args, cwd, result)
|
|
453
|
+
if appended:
|
|
454
|
+
result = f"{result}\n{appended}"
|
|
455
|
+
|
|
456
|
+
if call.name in _NO_OFFLOAD or SESSION_ID is None:
|
|
457
|
+
return _cap_inline(result)
|
|
458
|
+
|
|
459
|
+
from . import artifacts
|
|
460
|
+
global _turn_chars_stored
|
|
461
|
+
if len(result) > artifacts.OFFLOAD_THRESHOLD:
|
|
462
|
+
if _turn_chars_stored >= TURN_RESULT_BUDGET_CHARS:
|
|
463
|
+
return _cap_inline(
|
|
464
|
+
f"{truncate(result)}\n"
|
|
465
|
+
f"[turn result budget of {TURN_RESULT_BUDGET_CHARS} chars "
|
|
466
|
+
f"exceeded; not offloaded to an artifact this turn]")
|
|
467
|
+
_turn_chars_stored += min(len(result), artifacts.RESULT_MAX_CHARS)
|
|
468
|
+
return _cap_inline(artifacts.offload_if_large(result, SESSION_ID))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@tool("find_tools",
|
|
472
|
+
"Search the deferred tool catalog (Linear, Notion, and other connected "
|
|
473
|
+
"MCP servers) by keyword. Returns tool names, descriptions and parameters. "
|
|
474
|
+
"Use this BEFORE call_tool when you need an integration, then pass the "
|
|
475
|
+
"exact name to call_tool.",
|
|
476
|
+
{"query": S, "limit": I}, ["query"])
|
|
477
|
+
async def _find_tools(query: str, limit: int = 8) -> str:
|
|
478
|
+
await mcp.ensure_loaded()
|
|
479
|
+
terms = [w for w in query.lower().split() if len(w) > 2]
|
|
480
|
+
scored: list[tuple[int, str, dict[str, Any]]] = []
|
|
481
|
+
for name, entry in deferred().items():
|
|
482
|
+
fn = entry.schema["function"]
|
|
483
|
+
hay = (name + " " + (fn.get("description") or "")).lower()
|
|
484
|
+
score = sum(hay.count(w) for w in terms)
|
|
485
|
+
if score:
|
|
486
|
+
scored.append((score, name, fn))
|
|
487
|
+
if not scored:
|
|
488
|
+
return (f"no tools matched {query!r}. "
|
|
489
|
+
f"{len(deferred())} tools available across "
|
|
490
|
+
f"{len({n.split('__')[1] for n in deferred()})} servers.")
|
|
491
|
+
scored.sort(key=lambda x: -x[0])
|
|
492
|
+
out = []
|
|
493
|
+
for _, name, fn in scored[:limit]:
|
|
494
|
+
params = list((fn.get("parameters") or {}).get("properties") or {})
|
|
495
|
+
req = (fn.get("parameters") or {}).get("required") or []
|
|
496
|
+
out.append(f"{name}\n {(fn.get('description') or '')[:280]}\n"
|
|
497
|
+
f" params: {', '.join(params[:14]) or '(none)'}"
|
|
498
|
+
f"{' required: ' + ', '.join(req) if req else ''}")
|
|
499
|
+
return truncate("\n\n".join(out), 8000)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@tool("call_tool",
|
|
503
|
+
"Invoke a tool found via find_tools. `name` must be an exact name from "
|
|
504
|
+
"find_tools output; `arguments` is a JSON object of its parameters.",
|
|
505
|
+
{"name": S, "arguments": {"type": "object"}}, ["name"], mutates=True)
|
|
506
|
+
async def _call_tool(name: str, arguments: ToolArgs | None = None) -> str:
|
|
507
|
+
await mcp.ensure_loaded()
|
|
508
|
+
entry = REGISTRY.get(name)
|
|
509
|
+
if entry is None or not entry.deferred:
|
|
510
|
+
return (f"error: {name!r} is not a deferred tool. "
|
|
511
|
+
f"Use find_tools first to get an exact name.")
|
|
512
|
+
fn = entry.fn
|
|
513
|
+
args = arguments or {}
|
|
514
|
+
if inspect.iscoroutinefunction(fn):
|
|
515
|
+
return cast(str, await fn(**args))
|
|
516
|
+
return cast(str, await asyncio.to_thread(fn, **args))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@tool("ask_user",
|
|
520
|
+
"The right tool for a design conversation -- batching 2-4 decisions "
|
|
521
|
+
"with options is expected before building anything non-trivial on an "
|
|
522
|
+
"open-ended request. Never use it for things you can look up yourself.",
|
|
523
|
+
{"question": S, "header": S,
|
|
524
|
+
"options": {"type": "array", "items": {"type": "object",
|
|
525
|
+
"properties": {"label": S, "description": S}}},
|
|
526
|
+
"multi_select": {"type": "boolean"}},
|
|
527
|
+
["question"])
|
|
528
|
+
async def _ask_user(question: str, header: str = "", options: list[Option] | None = None,
|
|
529
|
+
multi_select: bool = False) -> str:
|
|
530
|
+
if ASK_USER is None:
|
|
531
|
+
return ("error: ask_user requires an interactive session; omega is "
|
|
532
|
+
"running non-interactively. State your assumption and "
|
|
533
|
+
"proceed instead.")
|
|
534
|
+
async with _confirm_lock:
|
|
535
|
+
return await ASK_USER(question, options or [], multi_select)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@tool("fetch_result",
|
|
539
|
+
"Read more of a large tool result that was saved as an artifact -- "
|
|
540
|
+
"the id is in the `[full output: ... saved as artifact <id>]` footer. "
|
|
541
|
+
"Each page ends with a trailer showing `next_offset` for the next call, "
|
|
542
|
+
"or `[end]`.",
|
|
543
|
+
{"id": S, "offset": I, "limit": I}, ["id"])
|
|
544
|
+
def _fetch_result(id: str, offset: int = 0, limit: int = 0) -> str:
|
|
545
|
+
if SESSION_ID is None:
|
|
546
|
+
return "error: no active session"
|
|
547
|
+
from . import artifacts
|
|
548
|
+
# 0 means "not specified" -- the actual default (PAGE_CHARS) lives in
|
|
549
|
+
# artifacts.py, which tools.py cannot import at module scope (artifacts
|
|
550
|
+
# imports `truncate` from here, so a top-level cycle would deadlock init).
|
|
551
|
+
return artifacts.fetch(SESSION_ID, id, offset, limit or artifacts.PAGE_CHARS)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@tool("list_artifacts",
|
|
555
|
+
"List the artifacts saved so far this session (offloaded tool "
|
|
556
|
+
"outputs and content saved with save_artifact) -- id, kind, size, title.",
|
|
557
|
+
{}, [])
|
|
558
|
+
def _list_artifacts() -> str:
|
|
559
|
+
if SESSION_ID is None:
|
|
560
|
+
return "(no artifacts this session)"
|
|
561
|
+
from . import artifacts
|
|
562
|
+
rows = artifacts.list_artifacts(SESSION_ID)
|
|
563
|
+
if not rows:
|
|
564
|
+
return "(no artifacts this session)"
|
|
565
|
+
return "\n".join(f"{r['id']} {r['kind']} {r['size']} chars {r['title']}"
|
|
566
|
+
for r in rows)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@tool("save_artifact",
|
|
570
|
+
"Persist long-form content you are building up (a plan, a report) so "
|
|
571
|
+
"you don't have to re-emit it every turn; returns an id you can update "
|
|
572
|
+
"later.",
|
|
573
|
+
{"title": S, "content": S}, ["title", "content"], mutates=True)
|
|
574
|
+
def _save_artifact(title: str, content: str) -> str:
|
|
575
|
+
if SESSION_ID is None:
|
|
576
|
+
return "error: no active session"
|
|
577
|
+
from . import artifacts
|
|
578
|
+
artifact_id = artifacts.save(SESSION_ID, content, title=title, kind="authored")
|
|
579
|
+
return f"saved artifact {artifact_id} ({len(content)} chars)"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@tool("update_artifact",
|
|
583
|
+
"Replace the full content of an artifact you previously created with "
|
|
584
|
+
"save_artifact (full replace, no diffing).",
|
|
585
|
+
{"id": S, "content": S}, ["id", "content"], mutates=True)
|
|
586
|
+
def _update_artifact(id: str, content: str) -> str:
|
|
587
|
+
if SESSION_ID is None:
|
|
588
|
+
return "error: no active session"
|
|
589
|
+
from . import artifacts
|
|
590
|
+
return artifacts.update(SESSION_ID, id, content)
|