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/llm.py ADDED
@@ -0,0 +1,442 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ from collections.abc import AsyncIterator, Awaitable, Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ import anthropic
9
+ import openai
10
+ from anthropic import AsyncAnthropic
11
+ from openai import AsyncOpenAI
12
+
13
+ from .config import Role
14
+ from .session import Message
15
+
16
+ PhaseState = Literal["waiting", "thinking", "streaming"]
17
+ FallbackInfo = tuple[str, str, str]
18
+ StreamEvent = (tuple[Literal["text"], str] | tuple[Literal["tool"], "ToolCall"]
19
+ | tuple[Literal["done"], "Turn"] | tuple[Literal["phase"], PhaseState]
20
+ | tuple[Literal["fallback"], FallbackInfo])
21
+
22
+ # The array-form fallback header would also work, but the scalar "default" mode
23
+ # picks Anthropic's recommended fallback per refusal category instead of
24
+ # pinning one model -- see shared/model-migration.md -> New API features.
25
+ _ANTHROPIC_BETAS = ["server-side-fallback-2026-07-01"]
26
+ _ANTHROPIC_MAX_TOKENS = 64000
27
+ _VOLATILE_MARKER = "\n<!-- volatile -->\n"
28
+
29
+ # Retry ladder shared by both backends: retried only while no output has
30
+ # reached the caller yet -- a mid-stream failure must surface as an error,
31
+ # never silently restart and duplicate what was already emitted.
32
+ LLM_MAX_ATTEMPTS = 3
33
+ LLM_RETRY_BASE_DELAY = 1.0
34
+ _RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504, 529}
35
+ _RETRYABLE_EXC = (openai.APIStatusError, openai.APIConnectionError,
36
+ anthropic.APIStatusError, anthropic.APIConnectionError)
37
+ # Swapped out by tests to avoid real waits; production code must not call
38
+ # asyncio.sleep directly so a monkeypatch here covers both backends.
39
+ _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
40
+
41
+
42
+ def _is_retryable(exc: BaseException) -> bool:
43
+ if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
44
+ return exc.status_code in _RETRYABLE_STATUS
45
+ # APIConnectionError (incl. APITimeoutError, its subclass in both SDKs)
46
+ # never carries a status code -- network/timeout failures are always
47
+ # worth a retry.
48
+ return isinstance(exc, (openai.APIConnectionError, anthropic.APIConnectionError))
49
+
50
+
51
+ def _retry_delay(attempt: int, exc: BaseException) -> float:
52
+ if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
53
+ retry_after = exc.response.headers.get("retry-after")
54
+ if retry_after:
55
+ try:
56
+ return float(retry_after)
57
+ except ValueError:
58
+ pass
59
+ base = LLM_RETRY_BASE_DELAY * (2.0 ** (attempt - 1))
60
+ return base + random.uniform(0, base * 0.5)
61
+
62
+
63
+ def _fallback_reason(exc: BaseException) -> str:
64
+ if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
65
+ return f"HTTP {exc.status_code}"
66
+ return type(exc).__name__
67
+
68
+
69
+ @dataclass
70
+ class ToolCall:
71
+ id: str
72
+ name: str
73
+ arguments: str = ""
74
+
75
+ def args(self) -> dict[str, Any]:
76
+ try:
77
+ return dict(json.loads(self.arguments or "{}"))
78
+ except json.JSONDecodeError as e:
79
+ raise ValueError(f"bad arguments for {self.name}: {e}") from None
80
+
81
+
82
+ @dataclass
83
+ class Turn:
84
+ text: str = ""
85
+ tool_calls: list[ToolCall] = field(default_factory=list)
86
+ finish_reason: str = ""
87
+ prompt_tokens: int = 0
88
+ completion_tokens: int = 0
89
+ cached_tokens: int = 0
90
+ cache_creation_tokens: int = 0
91
+ thinking: list[dict[str, Any]] = field(default_factory=list)
92
+ model: str = ""
93
+
94
+ def as_message(self) -> Message:
95
+ msg: Message = {"role": "assistant", "content": self.text or None}
96
+ if self.tool_calls:
97
+ msg["tool_calls"] = [
98
+ {"id": c.id, "type": "function",
99
+ "function": {"name": c.name, "arguments": c.arguments or "{}"}}
100
+ for c in self.tool_calls
101
+ ]
102
+ if self.thinking:
103
+ msg["thinking"] = self.thinking
104
+ if self.model:
105
+ msg["model"] = self.model
106
+ return msg
107
+
108
+
109
+ _clients: dict[str, AsyncOpenAI] = {}
110
+ _anthropic_clients: dict[str, AsyncAnthropic] = {}
111
+
112
+
113
+ def client_for(role: Role) -> AsyncOpenAI:
114
+ p = role.provider
115
+ if p.name not in _clients:
116
+ _clients[p.name] = AsyncOpenAI(base_url=p.base_url, api_key=p.api_key)
117
+ return _clients[p.name]
118
+
119
+
120
+ def _anthropic_client_for(role: Role) -> AsyncAnthropic:
121
+ p = role.provider
122
+ if p.name not in _anthropic_clients:
123
+ kwargs: dict[str, Any] = {"api_key": p.api_key}
124
+ if p.base_url:
125
+ kwargs["base_url"] = p.base_url
126
+ _anthropic_clients[p.name] = AsyncAnthropic(**kwargs)
127
+ return _anthropic_clients[p.name]
128
+
129
+
130
+ def _backend_for(role: Role) -> Callable[[Role, list[Message], list[dict[str, Any]] | None],
131
+ AsyncIterator[StreamEvent]]:
132
+ return _stream_anthropic if getattr(role.provider, "type", "openai") == "anthropic" else _stream_openai
133
+
134
+
135
+ async def _stream_with_retries(role: Role, messages: list[Message],
136
+ tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
137
+ """The retry ladder for one role/backend: up to `LLM_MAX_ATTEMPTS` tries,
138
+ exponential backoff with jitter (honoring `retry-after` when present), on
139
+ HTTP 408/409/429/500/502/503/504/529 and connection/timeout errors.
140
+
141
+ Retried only while this attempt produced no `text`/`tool` output yet --
142
+ once the caller has seen output, a failure must propagate as an error
143
+ rather than silently restart and duplicate it.
144
+ """
145
+ backend = _backend_for(role)
146
+ last_exc: BaseException | None = None
147
+ for attempt in range(1, LLM_MAX_ATTEMPTS + 1):
148
+ yielded = False
149
+ try:
150
+ async for ev in backend(role, messages, tools):
151
+ if ev[0] in ("text", "tool"):
152
+ yielded = True
153
+ yield ev
154
+ return
155
+ except _RETRYABLE_EXC as exc:
156
+ if yielded or attempt == LLM_MAX_ATTEMPTS or not _is_retryable(exc):
157
+ raise
158
+ last_exc = exc
159
+ yield "phase", "waiting"
160
+ await _sleep(_retry_delay(attempt, exc))
161
+ if last_exc is not None:
162
+ raise last_exc
163
+
164
+
165
+ async def stream(role: Role, messages: list[Message],
166
+ tools: list[dict[str, Any]] | None = None,
167
+ fallback: Role | None = None) -> AsyncIterator[StreamEvent]:
168
+ """Yield ('text', delta) and ('tool', ToolCall) events.
169
+
170
+ A tool call is emitted the moment its arguments are known to be complete --
171
+ when a higher-indexed call begins, or when the stream ends -- so the caller
172
+ can start executing it while the model is still generating the next one.
173
+
174
+ Dispatches to the OpenAI-compatible or native Anthropic backend based on
175
+ `role.provider.type` (defaults to "openai" for callers -- tests included --
176
+ that build a bare provider stand-in without a `type` attribute).
177
+
178
+ Wraps the call in a retry ladder (see `_stream_with_retries`); if it is
179
+ exhausted -- or the failure isn't retryable at all -- and `fallback` is
180
+ given, retries the whole thing against the fallback role (with its own
181
+ retry ladder), announced via a `("fallback", (from, to, reason))` event.
182
+ Falls back only when nothing has reached the caller yet, for the same
183
+ no-duplicate-output reason the retry ladder itself stops retrying.
184
+ """
185
+ any_output = False
186
+ try:
187
+ async for ev in _stream_with_retries(role, messages, tools):
188
+ if ev[0] in ("text", "tool"):
189
+ any_output = True
190
+ yield ev
191
+ except _RETRYABLE_EXC as exc:
192
+ if any_output or fallback is None:
193
+ raise
194
+ yield "phase", "waiting"
195
+ yield "fallback", (role.model, fallback.model, _fallback_reason(exc))
196
+ async for ev in _stream_with_retries(fallback, messages, tools):
197
+ yield ev
198
+
199
+
200
+ async def _stream_openai(role: Role, messages: list[Message],
201
+ tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
202
+ # `thinking`/`model` are Anthropic-only bookkeeping stamped by as_message();
203
+ # some OpenAI-compatible providers hard-reject unrecognized message keys.
204
+ sent = [{k: v for k, v in m.items() if k not in ("thinking", "model")} for m in messages]
205
+ kwargs: dict[str, Any] = {"model": role.model, "messages": sent, "stream": True,
206
+ "stream_options": {"include_usage": True}}
207
+ if tools:
208
+ kwargs["tools"] = tools
209
+ kwargs["tool_choice"] = "auto"
210
+
211
+ pending: dict[int, ToolCall] = {}
212
+ turn = Turn(model=role.model)
213
+ emitted: set[int] = set()
214
+
215
+ async for chunk in await client_for(role).chat.completions.create(**kwargs):
216
+ # The usage-bearing chunk arrives last with an EMPTY choices array, so
217
+ # it must be read before the choices guard or it is silently discarded.
218
+ if getattr(chunk, "usage", None):
219
+ u = chunk.usage
220
+ turn.prompt_tokens = getattr(u, "prompt_tokens", 0) or 0
221
+ turn.completion_tokens = getattr(u, "completion_tokens", 0) or 0
222
+ details = getattr(u, "prompt_tokens_details", None)
223
+ turn.cached_tokens = (getattr(details, "cached_tokens", 0) or 0) if details else 0
224
+ if not chunk.choices:
225
+ continue
226
+ choice = chunk.choices[0]
227
+ delta = choice.delta
228
+
229
+ if delta and delta.content:
230
+ turn.text += delta.content
231
+ yield "text", delta.content
232
+
233
+ for tc in (delta.tool_calls or []) if delta else []:
234
+ # A provider that reuses an index for a new call would otherwise
235
+ # concatenate two names into one ("greplread").
236
+ existing = pending.get(tc.index)
237
+ if (existing is not None and tc.id and existing.id
238
+ and tc.id != existing.id and not existing.id.startswith("call_")):
239
+ if tc.index not in emitted:
240
+ emitted.add(tc.index)
241
+ turn.tool_calls.append(existing)
242
+ yield "tool", existing
243
+ del pending[tc.index]
244
+ if tc.index not in pending:
245
+ for done in sorted(i for i in pending if i < tc.index and i not in emitted):
246
+ emitted.add(done)
247
+ turn.tool_calls.append(pending[done])
248
+ yield "tool", pending[done]
249
+ pending[tc.index] = ToolCall(id=tc.id or f"call_{tc.index}", name="")
250
+ call = pending[tc.index]
251
+ if tc.id:
252
+ call.id = tc.id
253
+ if tc.function and tc.function.name:
254
+ call.name += tc.function.name
255
+ if tc.function and tc.function.arguments:
256
+ call.arguments += tc.function.arguments
257
+
258
+ if choice.finish_reason:
259
+ turn.finish_reason = choice.finish_reason
260
+
261
+ incomplete: list[str] = []
262
+ for i in sorted(i for i in pending if i not in emitted):
263
+ call = pending[i]
264
+ # finish_reason "length" means generation was cut off mid-call: its
265
+ # arguments are truncated JSON and must never be dispatched or stored.
266
+ if turn.finish_reason == "length" and not _parseable(call.arguments):
267
+ incomplete.append(call.name or f"index {i}")
268
+ continue
269
+ turn.tool_calls.append(call)
270
+ yield "tool", call
271
+
272
+ if incomplete:
273
+ turn.text += (f"\n[turn truncated by token limit; dropped incomplete "
274
+ f"tool call(s): {', '.join(incomplete)}]")
275
+
276
+ yield "done", turn
277
+
278
+
279
+ def _parseable(args: str) -> bool:
280
+ try:
281
+ json.loads(args or "{}")
282
+ return True
283
+ except json.JSONDecodeError:
284
+ return False
285
+
286
+
287
+ def _anthropic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
288
+ out = []
289
+ for t in tools:
290
+ fn = t.get("function", t)
291
+ out.append({"name": fn["name"], "description": fn.get("description", ""),
292
+ "input_schema": fn.get("parameters") or {"type": "object", "properties": {}}})
293
+ return out
294
+
295
+
296
+ def _anthropic_history(history: list[Message], role: Role) -> list[dict[str, Any]]:
297
+ """Convert omega's OpenAI-shaped history (after the leading system message)
298
+ into Messages API turns. Consecutive `tool` messages collapse into one
299
+ user message holding all their `tool_result` blocks -- the API rejects a
300
+ `tool_result` that isn't inside a user message immediately following the
301
+ `tool_use` turn. Thinking blocks are only replayed on the model that
302
+ produced them (see the module docstring in compact.py for why)."""
303
+ out: list[dict[str, Any]] = []
304
+ i = 0
305
+ while i < len(history):
306
+ m = history[i]
307
+ role_name = m.get("role")
308
+ if role_name == "user":
309
+ out.append({"role": "user", "content": [{"type": "text", "text": str(m.get("content") or "")}]})
310
+ i += 1
311
+ elif role_name == "assistant":
312
+ content: list[dict[str, Any]] = []
313
+ if m.get("model") == role.model:
314
+ content.extend(m.get("thinking") or [])
315
+ if m.get("content"):
316
+ content.append({"type": "text", "text": str(m["content"])})
317
+ for tc in m.get("tool_calls") or []:
318
+ fn = tc["function"]
319
+ try:
320
+ call_input = json.loads(fn.get("arguments") or "{}")
321
+ except json.JSONDecodeError:
322
+ call_input = {}
323
+ content.append({"type": "tool_use", "id": tc["id"], "name": fn["name"], "input": call_input})
324
+ out.append({"role": "assistant", "content": content})
325
+ i += 1
326
+ elif role_name == "tool":
327
+ blocks = []
328
+ while i < len(history) and history[i].get("role") == "tool":
329
+ t = history[i]
330
+ blocks.append({"type": "tool_result", "tool_use_id": t["tool_call_id"],
331
+ "content": str(t.get("content") or "")})
332
+ i += 1
333
+ out.append({"role": "user", "content": blocks})
334
+ else:
335
+ i += 1
336
+ return out
337
+
338
+
339
+ def _anthropic_system_blocks(system_text: str) -> list[dict[str, Any]]:
340
+ """Split on the stable/volatile marker `loop.py` renders the system prompt
341
+ with. The stable prefix (persona, tool-use conventions -- identical every
342
+ turn) gets the long-lived breakpoint; the volatile tail (memory snapshot,
343
+ per-session notes) is a separate, uncached block so editing it never
344
+ invalidates the expensive stable prefix. Render order is tools -> system
345
+ -> messages, so tool schemas sit ahead of this and are cached for free.
346
+ """
347
+ stable, _, volatile = system_text.partition(_VOLATILE_MARKER)
348
+ blocks = [{"type": "text", "text": stable,
349
+ "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
350
+ if volatile:
351
+ blocks.append({"type": "text", "text": volatile})
352
+ return blocks
353
+
354
+
355
+ async def _stream_anthropic(role: Role, messages: list[Message],
356
+ tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
357
+ system_text = str(messages[0].get("content") or "") if messages and messages[0].get("role") == "system" else ""
358
+ body = messages[1:] if messages and messages[0].get("role") == "system" else messages
359
+ anth_messages = _anthropic_history(body, role)
360
+ if anth_messages and anth_messages[-1]["content"]:
361
+ # Breakpoint on the last block of the last turn: every earlier
362
+ # breakpoint stays a valid read, so hits accrue as history grows.
363
+ anth_messages[-1]["content"][-1] = {
364
+ **anth_messages[-1]["content"][-1], "cache_control": {"type": "ephemeral"}}
365
+
366
+ kwargs: dict[str, Any] = {
367
+ "model": role.model,
368
+ "max_tokens": _ANTHROPIC_MAX_TOKENS,
369
+ "system": _anthropic_system_blocks(system_text),
370
+ "messages": anth_messages,
371
+ "betas": _ANTHROPIC_BETAS,
372
+ "fallbacks": "default",
373
+ }
374
+ if tools:
375
+ kwargs["tools"] = _anthropic_tools(tools)
376
+ # Fable 5.1 thinking is always on and rejects any explicit `thinking` config
377
+ # (including "adaptive"); every other claude-* model needs it spelled out.
378
+ if not role.model.startswith("claude-fable") and role.model.startswith("claude"):
379
+ kwargs["thinking"] = {"type": "adaptive"}
380
+ if role.effort:
381
+ kwargs["output_config"] = {"effort": role.effort}
382
+
383
+ turn = Turn(model=role.model)
384
+ pending_tools: dict[int, ToolCall] = {}
385
+ in_thinking = False
386
+
387
+ client = _anthropic_client_for(role)
388
+ async with client.beta.messages.stream(**kwargs) as anthropic_stream:
389
+ async for event in anthropic_stream:
390
+ if event.type == "content_block_start":
391
+ if event.content_block.type in ("thinking", "redacted_thinking"):
392
+ if not in_thinking:
393
+ in_thinking = True
394
+ yield "phase", "thinking"
395
+ else:
396
+ if in_thinking:
397
+ in_thinking = False
398
+ yield "phase", "streaming"
399
+ if event.content_block.type == "tool_use":
400
+ pending_tools[event.index] = ToolCall(
401
+ id=event.content_block.id, name=event.content_block.name)
402
+ elif event.type == "content_block_delta":
403
+ if event.delta.type == "thinking_delta" and not in_thinking:
404
+ in_thinking = True
405
+ yield "phase", "thinking"
406
+ elif event.delta.type == "text_delta":
407
+ if in_thinking:
408
+ in_thinking = False
409
+ yield "phase", "streaming"
410
+ turn.text += event.delta.text
411
+ yield "text", event.delta.text
412
+ elif event.delta.type == "input_json_delta":
413
+ call = pending_tools.get(event.index)
414
+ if call is not None:
415
+ call.arguments += event.delta.partial_json
416
+ elif event.type == "content_block_stop" and event.index in pending_tools:
417
+ call = pending_tools.pop(event.index)
418
+ turn.tool_calls.append(call)
419
+ yield "tool", call
420
+ final = await anthropic_stream.get_final_message()
421
+
422
+ turn.prompt_tokens = final.usage.input_tokens
423
+ turn.completion_tokens = final.usage.output_tokens
424
+ turn.cached_tokens = final.usage.cache_read_input_tokens or 0
425
+ turn.cache_creation_tokens = getattr(final.usage, "cache_creation_input_tokens", 0) or 0
426
+ turn.thinking = [b.model_dump() for b in final.content if b.type in ("thinking", "redacted_thinking")]
427
+
428
+ if final.stop_reason == "max_tokens":
429
+ turn.finish_reason = "length"
430
+ elif final.stop_reason == "tool_use":
431
+ turn.finish_reason = "tool_calls"
432
+ elif final.stop_reason == "refusal":
433
+ turn.finish_reason = "refusal"
434
+ note = "\n[request declined by the model's safety classifiers"
435
+ served_by = getattr(final, "model", "") or ""
436
+ if served_by and served_by != role.model:
437
+ note += f"; fallback model {served_by} was tried but also declined"
438
+ turn.text += note + "]"
439
+ else:
440
+ turn.finish_reason = final.stop_reason or ""
441
+
442
+ yield "done", turn