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/eval/report.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .manifest import ContextManifest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RunResult:
|
|
12
|
+
task: str
|
|
13
|
+
model: str
|
|
14
|
+
repeat: int
|
|
15
|
+
passed: bool
|
|
16
|
+
turns: int
|
|
17
|
+
tool_calls: dict[str, int]
|
|
18
|
+
tokens_in: int
|
|
19
|
+
tokens_out: int
|
|
20
|
+
cost_usd: float | None
|
|
21
|
+
wall_time_s: float
|
|
22
|
+
cache_hit_ratio: float | None
|
|
23
|
+
error: str | None
|
|
24
|
+
check_output: str
|
|
25
|
+
manifest: ContextManifest | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"task": self.task, "model": self.model, "repeat": self.repeat,
|
|
30
|
+
"passed": self.passed, "turns": self.turns, "tool_calls": self.tool_calls,
|
|
31
|
+
"tokens_in": self.tokens_in, "tokens_out": self.tokens_out,
|
|
32
|
+
"cost_usd": self.cost_usd, "wall_time_s": round(self.wall_time_s, 3),
|
|
33
|
+
"cache_hit_ratio": self.cache_hit_ratio, "error": self.error,
|
|
34
|
+
"check_output": self.check_output[:2000],
|
|
35
|
+
"manifest": self.manifest.to_dict() if self.manifest else None,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _result_from_dict(d: dict[str, Any]) -> RunResult:
|
|
40
|
+
return RunResult(
|
|
41
|
+
task=d["task"], model=d["model"], repeat=d["repeat"], passed=d["passed"],
|
|
42
|
+
turns=d["turns"], tool_calls=dict(d["tool_calls"]), tokens_in=d["tokens_in"],
|
|
43
|
+
tokens_out=d["tokens_out"], cost_usd=d["cost_usd"], wall_time_s=d["wall_time_s"],
|
|
44
|
+
cache_hit_ratio=d["cache_hit_ratio"], error=d["error"], check_output=d["check_output"],
|
|
45
|
+
# `compare` only needs the summary row -- reloading a full ContextManifest
|
|
46
|
+
# from JSON isn't worth the code, so the manifest stays report.json-only.
|
|
47
|
+
manifest=None,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Report:
|
|
53
|
+
created: float
|
|
54
|
+
results: tuple[RunResult, ...] = field(default_factory=tuple)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {"created": self.created, "results": [r.to_dict() for r in self.results]}
|
|
58
|
+
|
|
59
|
+
def summary_by_model(self) -> dict[str, dict[str, Any]]:
|
|
60
|
+
by_model: dict[str, list[RunResult]] = {}
|
|
61
|
+
for r in self.results:
|
|
62
|
+
by_model.setdefault(r.model, []).append(r)
|
|
63
|
+
out: dict[str, dict[str, Any]] = {}
|
|
64
|
+
for model, rows in by_model.items():
|
|
65
|
+
costs = [r.cost_usd for r in rows if r.cost_usd is not None]
|
|
66
|
+
out[model] = {
|
|
67
|
+
"runs": len(rows),
|
|
68
|
+
"pass_rate": sum(r.passed for r in rows) / len(rows),
|
|
69
|
+
"mean_cost_usd": (sum(costs) / len(costs)) if costs else None,
|
|
70
|
+
"mean_time_s": sum(r.wall_time_s for r in rows) / len(rows),
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_table(report: Report) -> str:
|
|
76
|
+
if not report.results:
|
|
77
|
+
return "(no runs)"
|
|
78
|
+
header = (f"{'TASK':<30}{'MODEL':<10}{'RESULT':>7}{'TURNS':>7}{'TOOLS':>7}"
|
|
79
|
+
f"{'TOK IN':>9}{'TOK OUT':>9}{'$':>9}{'TIME':>8}")
|
|
80
|
+
lines = [header]
|
|
81
|
+
for r in report.results:
|
|
82
|
+
tool_total = sum(r.tool_calls.values())
|
|
83
|
+
cost = f"{r.cost_usd:.4f}" if r.cost_usd is not None else "-"
|
|
84
|
+
lines.append(f"{r.task[:29]:<30}{r.model:<10}{'PASS' if r.passed else 'FAIL':>7}"
|
|
85
|
+
f"{r.turns:>7}{tool_total:>7}{r.tokens_in:>9}{r.tokens_out:>9}"
|
|
86
|
+
f"{cost:>9}{r.wall_time_s:>7.1f}s")
|
|
87
|
+
lines.append("")
|
|
88
|
+
for model, s in sorted(report.summary_by_model().items()):
|
|
89
|
+
cost = f"${s['mean_cost_usd']:.4f}" if s["mean_cost_usd"] is not None else "$-"
|
|
90
|
+
lines.append(f"{model}: {s['pass_rate'] * 100:.0f}% pass · {cost} mean · "
|
|
91
|
+
f"{s['mean_time_s']:.1f}s mean · {s['runs']} run(s)")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compare(a: Report, b: Report) -> str:
|
|
96
|
+
def key(r: RunResult) -> tuple[str, str]:
|
|
97
|
+
return (r.task, r.model)
|
|
98
|
+
|
|
99
|
+
a_by = {key(r): r for r in a.results}
|
|
100
|
+
b_by = {key(r): r for r in b.results}
|
|
101
|
+
lines = [f"{'TASK':<30}{'MODEL':<10}{'PASS':>8}{'$ delta':>12}{'TIME delta':>14}"]
|
|
102
|
+
for k in sorted(set(a_by) | set(b_by)):
|
|
103
|
+
task, model = k
|
|
104
|
+
ra, rb = a_by.get(k), b_by.get(k)
|
|
105
|
+
if ra is None:
|
|
106
|
+
pass_change = "new(fail)" if not (rb and rb.passed) else "new(pass)"
|
|
107
|
+
elif rb is None:
|
|
108
|
+
pass_change = "gone"
|
|
109
|
+
elif ra.passed == rb.passed:
|
|
110
|
+
pass_change = "pass" if rb.passed else "fail"
|
|
111
|
+
else:
|
|
112
|
+
pass_change = "fixed" if rb.passed else "REGRESSED"
|
|
113
|
+
cost_delta = ("-" if not (ra and rb and ra.cost_usd is not None and rb.cost_usd is not None)
|
|
114
|
+
else f"{rb.cost_usd - ra.cost_usd:+.4f}")
|
|
115
|
+
time_delta = "-" if not (ra and rb) else f"{rb.wall_time_s - ra.wall_time_s:+.1f}s"
|
|
116
|
+
lines.append(f"{task[:29]:<30}{model:<10}{pass_change:>8}{cost_delta:>12}{time_delta:>14}")
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_report(report: Report, run_dir: Path) -> Path:
|
|
121
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
path = run_dir / "report.json"
|
|
123
|
+
tmp = path.with_suffix(".tmp")
|
|
124
|
+
tmp.write_text(json.dumps(report.to_dict(), indent=1))
|
|
125
|
+
tmp.replace(path)
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_report(path: Path) -> Report:
|
|
130
|
+
raw = json.loads(path.read_text())
|
|
131
|
+
return Report(created=raw["created"], results=tuple(_result_from_dict(r) for r in raw["results"]))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def new_run_dir(runs_root: Path) -> Path:
|
|
135
|
+
return runs_root / time.strftime("%Y%m%d-%H%M%S")
|
omega/eval/runner.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .. import events, loop, tools
|
|
11
|
+
from ..config import Config, Role
|
|
12
|
+
from ..session import Message
|
|
13
|
+
from .manifest import build_manifest
|
|
14
|
+
from .prices import estimate_cost
|
|
15
|
+
from .report import RunResult
|
|
16
|
+
from .tasks import Task
|
|
17
|
+
|
|
18
|
+
_CHECK_TIMEOUT_S = 300
|
|
19
|
+
|
|
20
|
+
# `tools._bash` resolves its working directory via `os.getcwd()`, which is
|
|
21
|
+
# process-wide -- two runs executing tools concurrently in different
|
|
22
|
+
# worktrees would race on which directory a bash call actually lands in.
|
|
23
|
+
# `--jobs` still bounds how many runs are in flight (workspace prep/cleanup
|
|
24
|
+
# and the model's own network waits overlap fine); this lock only serializes
|
|
25
|
+
# the tool-executing portion of each run, which is the part that touches cwd.
|
|
26
|
+
_TOOL_LOCK = asyncio.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Workspace:
|
|
31
|
+
path: Path
|
|
32
|
+
is_git_worktree: bool
|
|
33
|
+
source_repo: Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_models(cfg: Config, models_arg: str | None) -> list[tuple[str, Role]]:
|
|
37
|
+
"""`--models a,b,c` resolves each alias against the catalog; with no
|
|
38
|
+
`--models`, eval runs against whatever `main` currently points at."""
|
|
39
|
+
if not models_arg:
|
|
40
|
+
role = cfg.role("main")
|
|
41
|
+
return [(role.alias or "main", role)]
|
|
42
|
+
out: list[tuple[str, Role]] = []
|
|
43
|
+
for raw in models_arg.split(","):
|
|
44
|
+
alias = cfg.resolve_alias(raw.strip())
|
|
45
|
+
out.append((alias, cfg.model(alias)))
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _is_git_repo(repo: Path) -> bool:
|
|
50
|
+
proc = await asyncio.create_subprocess_exec(
|
|
51
|
+
"git", "-C", str(repo), "rev-parse", "--is-inside-work-tree",
|
|
52
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
53
|
+
await proc.communicate()
|
|
54
|
+
return proc.returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def prepare_workspace(task: Task, work_root: Path) -> Workspace:
|
|
58
|
+
repo = Path(task.repo).expanduser().resolve()
|
|
59
|
+
work_root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
dest = work_root / f"{task.name.replace(' ', '_')}-{secrets.token_hex(3)}"
|
|
61
|
+
is_git = await _is_git_repo(repo)
|
|
62
|
+
if is_git:
|
|
63
|
+
proc = await asyncio.create_subprocess_exec(
|
|
64
|
+
"git", "-C", str(repo), "worktree", "add", "--detach", str(dest), "HEAD",
|
|
65
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
66
|
+
_out, err = await proc.communicate()
|
|
67
|
+
if proc.returncode != 0:
|
|
68
|
+
raise RuntimeError(f"git worktree add failed: {err.decode(errors='replace')[:500]}")
|
|
69
|
+
else:
|
|
70
|
+
shutil.copytree(repo, dest)
|
|
71
|
+
|
|
72
|
+
if task.setup:
|
|
73
|
+
proc = await asyncio.create_subprocess_shell(
|
|
74
|
+
task.setup, cwd=str(dest),
|
|
75
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
76
|
+
_out, err = await proc.communicate()
|
|
77
|
+
if proc.returncode != 0:
|
|
78
|
+
raise RuntimeError(f"setup command failed ({proc.returncode}): "
|
|
79
|
+
f"{err.decode(errors='replace')[:2000]}")
|
|
80
|
+
return Workspace(path=dest, is_git_worktree=is_git, source_repo=repo)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def cleanup_workspace(ws: Workspace) -> None:
|
|
84
|
+
if ws.is_git_worktree:
|
|
85
|
+
proc = await asyncio.create_subprocess_exec(
|
|
86
|
+
"git", "-C", str(ws.source_repo), "worktree", "remove", "--force", str(ws.path),
|
|
87
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
88
|
+
await proc.communicate()
|
|
89
|
+
else:
|
|
90
|
+
shutil.rmtree(ws.path, ignore_errors=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _run_check(check: str, cwd: Path) -> tuple[bool, str]:
|
|
94
|
+
proc = await asyncio.create_subprocess_shell(
|
|
95
|
+
check, cwd=str(cwd),
|
|
96
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
|
97
|
+
try:
|
|
98
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=_CHECK_TIMEOUT_S)
|
|
99
|
+
except TimeoutError:
|
|
100
|
+
proc.kill()
|
|
101
|
+
await proc.communicate()
|
|
102
|
+
return False, f"check timed out after {_CHECK_TIMEOUT_S}s"
|
|
103
|
+
return proc.returncode == 0, out.decode(errors="replace")[-4000:]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def run_one(cfg: Config, task: Task, label: str, role: Role, ws: Workspace,
|
|
107
|
+
repeat: int = 0) -> RunResult:
|
|
108
|
+
"""Runs one task against one model in an already-prepared workspace.
|
|
109
|
+
Consumes `loop.run_agent` exactly as the CLI does -- `--yolo` semantics
|
|
110
|
+
(no CONFIRM/ASK_USER), events collected via `emit` instead of rendered."""
|
|
111
|
+
system, tool_names = loop.MODES[task.mode]
|
|
112
|
+
system = f"{system}\n{loop.UNTRUSTED_NOTE}"
|
|
113
|
+
schemas = tools.schemas(tool_names)
|
|
114
|
+
history: list[Message] = [{"role": "user", "content": task.prompt}]
|
|
115
|
+
initial_history = list(history)
|
|
116
|
+
collected: list[events.Event] = []
|
|
117
|
+
error: str | None = None
|
|
118
|
+
result_text = ""
|
|
119
|
+
|
|
120
|
+
started = time.monotonic()
|
|
121
|
+
prev_cwd = os.getcwd()
|
|
122
|
+
# tools.SESSION_ID/CONFIRM/ASK_USER/TAINTED are process globals `tools.run`
|
|
123
|
+
# reads directly -- this run isn't the only thing that may touch them
|
|
124
|
+
# (a caller embedding the eval harness in a live session, or -- in
|
|
125
|
+
# tests -- an unrelated fixture), so snapshot and restore them rather
|
|
126
|
+
# than assuming None/False is always the right thing to leave behind.
|
|
127
|
+
prev_session_id, prev_confirm, prev_ask_user, prev_tainted = (
|
|
128
|
+
tools.SESSION_ID, tools.CONFIRM, tools.ASK_USER, tools.TAINTED)
|
|
129
|
+
async with _TOOL_LOCK:
|
|
130
|
+
tools.SESSION_ID = f"eval-{task.name}-{label}-{repeat}-{secrets.token_hex(3)}"
|
|
131
|
+
tools.CONFIRM = None
|
|
132
|
+
tools.ASK_USER = None
|
|
133
|
+
tools.set_tainted(False)
|
|
134
|
+
os.chdir(ws.path)
|
|
135
|
+
try:
|
|
136
|
+
result_text = await asyncio.wait_for(
|
|
137
|
+
loop.run_agent(cfg, "main" if task.mode == "build" else "plan",
|
|
138
|
+
system, history, tool_names, emit=collected.append, role=role),
|
|
139
|
+
timeout=task.timeout_s)
|
|
140
|
+
except TimeoutError:
|
|
141
|
+
error = f"timed out after {task.timeout_s}s"
|
|
142
|
+
except Exception as e:
|
|
143
|
+
error = f"{type(e).__name__}: {e}"
|
|
144
|
+
finally:
|
|
145
|
+
os.chdir(prev_cwd)
|
|
146
|
+
tools.SESSION_ID, tools.CONFIRM, tools.ASK_USER = (
|
|
147
|
+
prev_session_id, prev_confirm, prev_ask_user)
|
|
148
|
+
tools.set_tainted(prev_tainted)
|
|
149
|
+
wall_time_s = time.monotonic() - started
|
|
150
|
+
|
|
151
|
+
passed, check_output = False, ""
|
|
152
|
+
if error is None:
|
|
153
|
+
(ws.path / "TRANSCRIPT.md").write_text(result_text)
|
|
154
|
+
passed, check_output = await _run_check(task.check, ws.path)
|
|
155
|
+
else:
|
|
156
|
+
check_output = error
|
|
157
|
+
|
|
158
|
+
turns = sum(1 for ev in collected if isinstance(ev, events.Phase) and ev.state == "waiting")
|
|
159
|
+
tool_calls: dict[str, int] = {}
|
|
160
|
+
for ev in collected:
|
|
161
|
+
if isinstance(ev, events.ToolStart):
|
|
162
|
+
tool_calls[ev.name] = tool_calls.get(ev.name, 0) + 1
|
|
163
|
+
usage_events = [ev for ev in collected if isinstance(ev, events.Usage)]
|
|
164
|
+
tokens_in = sum(ev.prompt_tokens for ev in usage_events)
|
|
165
|
+
tokens_out = sum(ev.completion_tokens for ev in usage_events)
|
|
166
|
+
cache_total = sum(getattr(ev, "cached_tokens", None) or getattr(ev, "cache_read", None) or 0
|
|
167
|
+
for ev in usage_events)
|
|
168
|
+
cache_hit_ratio = (cache_total / tokens_in) if tokens_in else None
|
|
169
|
+
manifest = build_manifest(collected, system, schemas, initial_history, history)
|
|
170
|
+
|
|
171
|
+
return RunResult(task=task.name, model=label, repeat=repeat, passed=passed, turns=turns,
|
|
172
|
+
tool_calls=tool_calls, tokens_in=tokens_in, tokens_out=tokens_out,
|
|
173
|
+
cost_usd=estimate_cost(label, tokens_in, tokens_out),
|
|
174
|
+
wall_time_s=wall_time_s, cache_hit_ratio=cache_hit_ratio,
|
|
175
|
+
error=error, check_output=check_output, manifest=manifest)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def run_suite(cfg: Config, tasks: list[Task], roles: list[tuple[str, Role]],
|
|
179
|
+
repeat: int = 1, jobs: int = 1,
|
|
180
|
+
work_root: Path | None = None) -> list[RunResult]:
|
|
181
|
+
owns_root = work_root is None
|
|
182
|
+
work_root = work_root or Path(tempfile.mkdtemp(prefix="omega-eval-"))
|
|
183
|
+
sem = asyncio.Semaphore(max(1, jobs))
|
|
184
|
+
|
|
185
|
+
async def _one(task: Task, label: str, role: Role, i: int) -> RunResult:
|
|
186
|
+
async with sem:
|
|
187
|
+
ws = await prepare_workspace(task, work_root)
|
|
188
|
+
try:
|
|
189
|
+
return await run_one(cfg, task, label, role, ws, repeat=i)
|
|
190
|
+
finally:
|
|
191
|
+
await cleanup_workspace(ws)
|
|
192
|
+
|
|
193
|
+
coros = [_one(task, label, role, i)
|
|
194
|
+
for task in tasks for label, role in roles for i in range(repeat)]
|
|
195
|
+
try:
|
|
196
|
+
return list(await asyncio.gather(*coros))
|
|
197
|
+
finally:
|
|
198
|
+
if owns_root:
|
|
199
|
+
shutil.rmtree(work_root, ignore_errors=True)
|
omega/eval/tasks.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
Mode = Literal["build", "plan"]
|
|
8
|
+
|
|
9
|
+
_REQUIRED = ("name", "prompt", "check")
|
|
10
|
+
_MODES = ("build", "plan")
|
|
11
|
+
|
|
12
|
+
EXAMPLES_DIR = Path(__file__).parent / "examples"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskError(ValueError):
|
|
16
|
+
"""A task file is missing a required field, has an invalid value, or the
|
|
17
|
+
given path/directory doesn't resolve to any task -- distinct from
|
|
18
|
+
ValueError so callers can catch just this without swallowing unrelated
|
|
19
|
+
bugs in yaml.safe_load or dataclass construction."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Task:
|
|
24
|
+
name: str
|
|
25
|
+
prompt: str
|
|
26
|
+
check: str
|
|
27
|
+
repo: str = "."
|
|
28
|
+
setup: str | None = None
|
|
29
|
+
timeout_s: int = 600
|
|
30
|
+
mode: Mode = "build"
|
|
31
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
32
|
+
source: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_task(raw: dict[str, Any], source: str = "") -> Task:
|
|
36
|
+
if not isinstance(raw, dict):
|
|
37
|
+
raise TaskError(f"{source}: task must be a YAML mapping, got {type(raw).__name__}")
|
|
38
|
+
missing = [f for f in _REQUIRED if not raw.get(f)]
|
|
39
|
+
if missing:
|
|
40
|
+
raise TaskError(f"{source}: missing required field(s): {', '.join(missing)}")
|
|
41
|
+
|
|
42
|
+
mode = raw.get("mode", "build")
|
|
43
|
+
if mode not in _MODES:
|
|
44
|
+
raise TaskError(f"{source}: mode must be one of {_MODES}, got {mode!r}")
|
|
45
|
+
|
|
46
|
+
timeout_s = raw.get("timeout_s", 600)
|
|
47
|
+
if not isinstance(timeout_s, int) or isinstance(timeout_s, bool) or timeout_s <= 0:
|
|
48
|
+
raise TaskError(f"{source}: timeout_s must be a positive integer, got {timeout_s!r}")
|
|
49
|
+
|
|
50
|
+
tags = raw.get("tags") or []
|
|
51
|
+
if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
|
|
52
|
+
raise TaskError(f"{source}: tags must be a list of strings")
|
|
53
|
+
|
|
54
|
+
setup = raw.get("setup")
|
|
55
|
+
return Task(
|
|
56
|
+
name=str(raw["name"]), prompt=str(raw["prompt"]), check=str(raw["check"]),
|
|
57
|
+
repo=str(raw.get("repo", ".")), setup=(str(setup) if setup else None),
|
|
58
|
+
timeout_s=timeout_s, mode=mode, tags=tuple(tags), source=source,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_task_file(path: Path) -> Task:
|
|
63
|
+
try:
|
|
64
|
+
raw = yaml.safe_load(path.read_text())
|
|
65
|
+
except yaml.YAMLError as e:
|
|
66
|
+
raise TaskError(f"{path}: invalid YAML: {e}") from e
|
|
67
|
+
return parse_task(raw or {}, source=str(path))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def discover_task_files(path: str | None) -> list[Path]:
|
|
71
|
+
"""`path=None` is the project default `.omega/evals/`; a directory globs
|
|
72
|
+
its *.yaml/*.yml; a file is used as-is."""
|
|
73
|
+
if path is None:
|
|
74
|
+
root = Path.cwd() / ".omega" / "evals"
|
|
75
|
+
if not root.exists():
|
|
76
|
+
return []
|
|
77
|
+
return sorted(root.glob("*.yaml")) + sorted(root.glob("*.yml"))
|
|
78
|
+
p = Path(path).expanduser()
|
|
79
|
+
if p.is_dir():
|
|
80
|
+
return sorted(p.glob("*.yaml")) + sorted(p.glob("*.yml"))
|
|
81
|
+
if p.is_file():
|
|
82
|
+
return [p]
|
|
83
|
+
raise TaskError(f"no such task file or directory: {p}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_tasks(path: str | None = None) -> list[Task]:
|
|
87
|
+
return [load_task_file(f) for f in discover_task_files(path)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def init_examples(dest: Path) -> list[Path]:
|
|
91
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
written = []
|
|
93
|
+
for src in sorted(EXAMPLES_DIR.glob("*.yaml")):
|
|
94
|
+
target = dest / src.name
|
|
95
|
+
target.write_text(src.read_text())
|
|
96
|
+
written.append(target)
|
|
97
|
+
return written
|
omega/events.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal, TypedDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Option(TypedDict, total=False):
|
|
6
|
+
"""An `ask_user` choice, as passed to `tools.ASK_USER` and rendered by both UIs."""
|
|
7
|
+
label: str
|
|
8
|
+
description: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class TextDelta:
|
|
13
|
+
text: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ToolStart:
|
|
18
|
+
call_id: str
|
|
19
|
+
name: str
|
|
20
|
+
args_preview: str
|
|
21
|
+
subagent_id: str | None = None
|
|
22
|
+
tier: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ToolEnd:
|
|
27
|
+
call_id: str
|
|
28
|
+
name: str
|
|
29
|
+
result_preview: str
|
|
30
|
+
duration_s: float
|
|
31
|
+
offloaded: bool
|
|
32
|
+
artifact_id: str | None = None
|
|
33
|
+
result_chars: int = 0
|
|
34
|
+
outcome: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Compacted:
|
|
39
|
+
note: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MemoryWrite:
|
|
44
|
+
node_id: str
|
|
45
|
+
type: str
|
|
46
|
+
title: str
|
|
47
|
+
scope: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class MemoryConsolidated:
|
|
52
|
+
summary: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class SubagentSpawned:
|
|
57
|
+
subagent_id: str
|
|
58
|
+
tier: str
|
|
59
|
+
task_preview: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SubagentDone:
|
|
64
|
+
subagent_id: str
|
|
65
|
+
summary_preview: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Error:
|
|
70
|
+
message: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Done:
|
|
75
|
+
text: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Usage:
|
|
80
|
+
prompt_tokens: int
|
|
81
|
+
completion_tokens: int
|
|
82
|
+
used: int
|
|
83
|
+
limit: int
|
|
84
|
+
cache_read: int = 0
|
|
85
|
+
cache_write: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Fallback:
|
|
90
|
+
from_model: str
|
|
91
|
+
to_model: str
|
|
92
|
+
reason: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ModelUsed:
|
|
97
|
+
alias: str | None
|
|
98
|
+
model: str
|
|
99
|
+
provider: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class Phase:
|
|
104
|
+
state: Literal["waiting", "thinking", "streaming", "tools", "idle"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Checkpoint:
|
|
109
|
+
"""A working-tree snapshot taken before a BUILD-mode turn -- see checkpoint.py."""
|
|
110
|
+
turn: int
|
|
111
|
+
id: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Verified:
|
|
116
|
+
"""One end-of-turn verification pass (possibly a retry) -- see verify.py."""
|
|
117
|
+
results_summary: str
|
|
118
|
+
ok: bool
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class JobStarted:
|
|
123
|
+
"""A `bash(..., background=True)` job began running -- see tools.py."""
|
|
124
|
+
id: str
|
|
125
|
+
command: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class JobFinished:
|
|
130
|
+
id: str
|
|
131
|
+
exit_code: int
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class RetryBlocked:
|
|
136
|
+
"""The harness refused to re-execute a tool call whose exact
|
|
137
|
+
(name, args) already failed repeatedly with the same error -- see
|
|
138
|
+
loop.py's per-turn repeat-fail guard."""
|
|
139
|
+
name: str
|
|
140
|
+
attempts: int
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
Event = (TextDelta | ToolStart | ToolEnd | Compacted | MemoryWrite |
|
|
144
|
+
MemoryConsolidated | SubagentSpawned | SubagentDone | Error | Done | Usage | ModelUsed | Phase |
|
|
145
|
+
Fallback | Checkpoint | Verified | JobStarted | JobFinished | RetryBlocked)
|
omega/export.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Session transcript -> Markdown, for `/export`. A pure function of
|
|
2
|
+
`history` (the same message list `session.Session` carries): user prompts as
|
|
3
|
+
`## › …`, assistant text verbatim, tool calls as a compact fenced list with
|
|
4
|
+
outcomes, and an offloaded result referenced by its artifact id rather than
|
|
5
|
+
re-embedded in full."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import session
|
|
14
|
+
from .session import Message
|
|
15
|
+
from .ui import format
|
|
16
|
+
|
|
17
|
+
_ARTIFACT_RE = re.compile(r"saved as artifact ([0-9a-f]+)")
|
|
18
|
+
_OUTCOME_CHARS = 120
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _tool_call_args(call: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
fn = call.get("function") or {}
|
|
23
|
+
try:
|
|
24
|
+
parsed = json.loads(fn.get("arguments") or "{}")
|
|
25
|
+
except json.JSONDecodeError:
|
|
26
|
+
return {}
|
|
27
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _outcome(result: str) -> str:
|
|
31
|
+
m = _ARTIFACT_RE.search(result)
|
|
32
|
+
if m:
|
|
33
|
+
return f"[artifact {m.group(1)}]"
|
|
34
|
+
flat = " ".join(result.split())
|
|
35
|
+
return flat if len(flat) <= _OUTCOME_CHARS else flat[:_OUTCOME_CHARS - 1] + "…"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tool_calls_block(tool_calls: list[dict[str, Any]], results: dict[str, str]) -> str:
|
|
39
|
+
lines = []
|
|
40
|
+
for call in tool_calls:
|
|
41
|
+
name = str((call.get("function") or {}).get("name", ""))
|
|
42
|
+
args = _tool_call_args(call)
|
|
43
|
+
result = results.get(str(call.get("id", "")), "")
|
|
44
|
+
lines.append(f"{format.describe_call(name, args)} → {_outcome(result)}")
|
|
45
|
+
return "```\n" + "\n".join(lines) + "\n```"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_markdown(history: list[Message], session_id: str = "") -> str:
|
|
49
|
+
results: dict[str, str] = {
|
|
50
|
+
str(m.get("tool_call_id", "")): str(m.get("content", ""))
|
|
51
|
+
for m in history if m.get("role") == "tool"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
parts: list[str] = [f"# omega session {session_id}"] if session_id else []
|
|
55
|
+
for msg in history:
|
|
56
|
+
role = msg.get("role")
|
|
57
|
+
if role == "user":
|
|
58
|
+
content = str(msg.get("content", ""))
|
|
59
|
+
if content.startswith(session.RESUME_PREFIX):
|
|
60
|
+
continue
|
|
61
|
+
parts.append(f"## › {content}")
|
|
62
|
+
elif role == "assistant":
|
|
63
|
+
assistant_text = msg.get("content")
|
|
64
|
+
if assistant_text:
|
|
65
|
+
parts.append(str(assistant_text))
|
|
66
|
+
tool_calls = msg.get("tool_calls")
|
|
67
|
+
if tool_calls:
|
|
68
|
+
parts.append(_tool_calls_block(tool_calls, results))
|
|
69
|
+
return "\n\n".join(parts) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_path(session_id: str) -> Path:
|
|
73
|
+
return session.DIR / session_id / "transcript.md"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write(history: list[Message], session_id: str, path: str | None = None) -> Path:
|
|
77
|
+
out = Path(path).expanduser() if path else default_path(session_id)
|
|
78
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
out.write_text(to_markdown(history, session_id))
|
|
80
|
+
return out
|