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.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. 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)