@agentikos/omega-os 0.1.0 → 0.2.0

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. package/README.md +25 -13
  2. package/bootstrap/lib/steps.sh +214 -9
  3. package/bootstrap/manifest.example.yaml +6 -1
  4. package/docs/COMPLETION-PLAN.md +48 -0
  5. package/omega/Agentik_Engine/README.md +25 -10
  6. package/omega/Agentik_Engine/omega_engine/__init__.py +66 -2
  7. package/omega/Agentik_Engine/omega_engine/account.py +505 -0
  8. package/omega/Agentik_Engine/omega_engine/autonomous.py +538 -0
  9. package/omega/Agentik_Engine/omega_engine/cli.py +467 -29
  10. package/omega/Agentik_Engine/omega_engine/daemons/__init__.py +14 -0
  11. package/omega/Agentik_Engine/omega_engine/daemons/autonomous.py +56 -0
  12. package/omega/Agentik_Engine/omega_engine/daemons/engine.py +187 -0
  13. package/omega/Agentik_Engine/omega_engine/daemons/telegram.py +231 -0
  14. package/omega/Agentik_Engine/omega_engine/educators/__init__.py +51 -0
  15. package/omega/Agentik_Engine/omega_engine/educators/artifact.py +65 -0
  16. package/omega/Agentik_Engine/omega_engine/educators/automation.py +76 -0
  17. package/omega/Agentik_Engine/omega_engine/educators/base.py +327 -0
  18. package/omega/Agentik_Engine/omega_engine/educators/claudecode.py +71 -0
  19. package/omega/Agentik_Engine/omega_engine/educators/connection.py +75 -0
  20. package/omega/Agentik_Engine/omega_engine/educators/coworker.py +68 -0
  21. package/omega/Agentik_Engine/omega_engine/educators/loop.py +82 -0
  22. package/omega/Agentik_Engine/omega_engine/educators/prompt.py +68 -0
  23. package/omega/Agentik_Engine/omega_engine/educators/skill.py +69 -0
  24. package/omega/Agentik_Engine/omega_engine/executor.py +46 -6
  25. package/omega/Agentik_Engine/omega_engine/mission.py +13 -1
  26. package/omega/Agentik_Engine/omega_engine/provider.py +247 -1
  27. package/omega/Agentik_Engine/omega_engine/rag/__init__.py +21 -0
  28. package/omega/Agentik_Engine/omega_engine/rag/agentic.py +83 -0
  29. package/omega/Agentik_Engine/omega_engine/rag/base.py +42 -0
  30. package/omega/Agentik_Engine/omega_engine/rag/corrective.py +119 -0
  31. package/omega/Agentik_Engine/omega_engine/rag/graph.py +169 -0
  32. package/omega/Agentik_Engine/omega_engine/rag/hybrid.py +205 -0
  33. package/omega/Agentik_Engine/omega_engine/rag/multimodal.py +136 -0
  34. package/omega/Agentik_Engine/omega_engine/rag/router.py +110 -0
  35. package/omega/Agentik_Engine/omega_engine/reducer.py +21 -3
  36. package/omega/Agentik_Engine/omega_engine/store.py +65 -5
  37. package/omega/Agentik_Engine/omega_engine/sync.py +304 -0
  38. package/omega/Agentik_Engine/omega_engine/tools.py +272 -0
  39. package/omega/Agentik_Engine/pyproject.toml +1 -1
  40. package/omega/Agentik_Engine/tests/test_account.py +333 -0
  41. package/omega/Agentik_Engine/tests/test_autonomous.py +361 -0
  42. package/omega/Agentik_Engine/tests/test_educators.py +233 -0
  43. package/omega/Agentik_Engine/tests/test_rag.py +287 -0
  44. package/omega/Agentik_Engine/tests/test_snapshot_partial.py +172 -0
  45. package/omega/Agentik_Engine/tests/test_tools_and_sync.py +312 -0
  46. package/omega/Agentik_SSOT/skills/rag-route.md +73 -0
  47. package/package.json +1 -1
  48. package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
  49. package/omega/Agentik_Engine/omega_engine/__pycache__/audit.cpython-313.pyc +0 -0
  50. package/omega/Agentik_Engine/omega_engine/__pycache__/audit_arsenal.cpython-313.pyc +0 -0
  51. package/omega/Agentik_Engine/omega_engine/__pycache__/barrier.cpython-313.pyc +0 -0
  52. package/omega/Agentik_Engine/omega_engine/__pycache__/bus.cpython-313.pyc +0 -0
  53. package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
  54. package/omega/Agentik_Engine/omega_engine/__pycache__/events.cpython-313.pyc +0 -0
  55. package/omega/Agentik_Engine/omega_engine/__pycache__/executor.cpython-313.pyc +0 -0
  56. package/omega/Agentik_Engine/omega_engine/__pycache__/mission.cpython-313.pyc +0 -0
  57. package/omega/Agentik_Engine/omega_engine/__pycache__/progress.cpython-313.pyc +0 -0
  58. package/omega/Agentik_Engine/omega_engine/__pycache__/project.cpython-313.pyc +0 -0
  59. package/omega/Agentik_Engine/omega_engine/__pycache__/provider.cpython-313.pyc +0 -0
  60. package/omega/Agentik_Engine/omega_engine/__pycache__/reducer.cpython-313.pyc +0 -0
  61. package/omega/Agentik_Engine/omega_engine/__pycache__/report.cpython-313.pyc +0 -0
  62. package/omega/Agentik_Engine/omega_engine/__pycache__/router.cpython-313.pyc +0 -0
  63. package/omega/Agentik_Engine/omega_engine/__pycache__/store.cpython-313.pyc +0 -0
  64. package/omega/Agentik_Engine/omega_engine/__pycache__/supervisor.cpython-313.pyc +0 -0
  65. package/omega/Agentik_Engine/omega_engine/__pycache__/task.cpython-313.pyc +0 -0
  66. package/omega/Agentik_Engine/omega_engine/__pycache__/telegram.cpython-313.pyc +0 -0
  67. package/omega/Agentik_Engine/tests/__pycache__/test_audit_arsenal.cpython-313.pyc +0 -0
  68. package/omega/Agentik_Engine/tests/__pycache__/test_executor.cpython-313.pyc +0 -0
  69. package/omega/Agentik_Engine/tests/__pycache__/test_mission.cpython-313.pyc +0 -0
  70. package/omega/Agentik_Engine/tests/__pycache__/test_progress.cpython-313.pyc +0 -0
  71. package/omega/Agentik_Engine/tests/__pycache__/test_project.cpython-313.pyc +0 -0
  72. package/omega/Agentik_Engine/tests/__pycache__/test_reducer.cpython-313.pyc +0 -0
  73. package/omega/Agentik_Engine/tests/__pycache__/test_report.cpython-313.pyc +0 -0
@@ -0,0 +1,272 @@
1
+ """The tool registry — Agentik_Tools/registry.json + install/remove logic.
2
+
3
+ Tools are third-party software that lands under `Agentik_Tools/<name>/`. Each
4
+ tool has one home and one invoke command. Source can be the MCP catalog
5
+ (`mcp-catalog.yaml`), a direct npm package spec, or a pre-existing tool the
6
+ installer already shipped (e.g. pdfgen).
7
+
8
+ The registry is the manifest of what is installed — it is git-tracked, the
9
+ tool payloads are not. On a new machine the bootstrap reads the registry and
10
+ reinstalls exactly the same set.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+ from dataclasses import asdict, dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ # --------------------------------------------------------------------------
25
+ # Tool dataclass + registry
26
+ # --------------------------------------------------------------------------
27
+
28
+
29
+ @dataclass
30
+ class Tool:
31
+ """One row in Agentik_Tools/registry.json."""
32
+
33
+ name: str
34
+ version: str = ""
35
+ path: str = "" # relative to OMEGA_HOME
36
+ invoke: str = "" # relative path to the binary
37
+ source: str = "" # npm package, mcp:<id>, etc.
38
+ providers: list[str] = field(default_factory=list) # provider ids that should see it
39
+ installed_at: str = ""
40
+ # Optional extras kept for MCP tools (server config + secret refs).
41
+ secret_refs: list[str] = field(default_factory=list)
42
+ enabled: bool = True
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ # Match the existing registry.json field naming for back-compat
46
+ # (camelCase for installedAt — see pdfgen entry shipped in the repo).
47
+ d = asdict(self)
48
+ d["installedAt"] = d.pop("installed_at", "")
49
+ return d
50
+
51
+ @staticmethod
52
+ def from_dict(d: dict[str, Any]) -> "Tool":
53
+ return Tool(
54
+ name=str(d.get("name", "")),
55
+ version=str(d.get("version", "")),
56
+ path=str(d.get("path", "")),
57
+ invoke=str(d.get("invoke", "")),
58
+ source=str(d.get("source", "")),
59
+ providers=list(d.get("providers", []) or []),
60
+ installed_at=str(d.get("installedAt", d.get("installed_at", "")) or ""),
61
+ secret_refs=list(d.get("secret_refs", []) or []),
62
+ enabled=bool(d.get("enabled", True)),
63
+ )
64
+
65
+
66
+ def _omega_home(explicit: str | Path | None = None) -> Path:
67
+ return Path(explicit or os.environ.get("OMEGA_HOME", str(Path.home() / "Omega")))
68
+
69
+
70
+ class ToolRegistry:
71
+ """Load, mutate, save Agentik_Tools/registry.json."""
72
+
73
+ def __init__(self, omega_home: Path, tools: list[Tool], version: int = 1) -> None:
74
+ self._home = omega_home
75
+ self._tools: dict[str, Tool] = {t.name: t for t in tools}
76
+ self._version = version
77
+
78
+ # ----- file io -------------------------------------------------------
79
+
80
+ @classmethod
81
+ def load(cls, omega_home: str | Path | None = None) -> "ToolRegistry":
82
+ home = _omega_home(omega_home)
83
+ rfile = home / "Agentik_Tools" / "registry.json"
84
+ if not rfile.exists():
85
+ return cls(home, [])
86
+ data = json.loads(rfile.read_text() or "{}")
87
+ tools = [Tool.from_dict(t) for t in data.get("tools", [])]
88
+ return cls(home, tools, version=int(data.get("version", 1)))
89
+
90
+ def save(self, omega_home: str | Path | None = None) -> None:
91
+ home = _omega_home(omega_home) if omega_home is not None else self._home
92
+ rfile = home / "Agentik_Tools" / "registry.json"
93
+ rfile.parent.mkdir(parents=True, exist_ok=True)
94
+ payload = {
95
+ "version": self._version,
96
+ "tools": [t.to_dict() for t in self._tools.values()],
97
+ }
98
+ rfile.write_text(json.dumps(payload, indent=2) + "\n")
99
+
100
+ # ----- queries -------------------------------------------------------
101
+
102
+ def list(self) -> list[Tool]:
103
+ return list(self._tools.values())
104
+
105
+ def get(self, name: str) -> Tool | None:
106
+ return self._tools.get(name)
107
+
108
+ def installed_path(self, name: str) -> Path:
109
+ tool = self.get(name)
110
+ if tool is None:
111
+ raise KeyError(f"tool not registered: {name}")
112
+ return self._home / tool.path
113
+
114
+ # ----- mutations -----------------------------------------------------
115
+
116
+ def install(self, tool: Tool) -> Tool:
117
+ """Register a tool (does not run the installer — see install_from_catalog)."""
118
+ if not tool.installed_at:
119
+ tool.installed_at = time.strftime("%Y-%m-%dT%H:%M:%S")
120
+ self._tools[tool.name] = tool
121
+ return tool
122
+
123
+ def remove(self, name: str) -> bool:
124
+ return self._tools.pop(name, None) is not None
125
+
126
+
127
+ # --------------------------------------------------------------------------
128
+ # MCP catalog → tool install
129
+ # --------------------------------------------------------------------------
130
+
131
+
132
+ class CatalogError(RuntimeError):
133
+ """The catalog is missing or the requested id is unknown."""
134
+
135
+
136
+ def load_catalog(omega_home: str | Path | None = None) -> dict[str, Any]:
137
+ """Read Agentik_SSOT/mcp/mcp-catalog.yaml. Raises CatalogError if missing."""
138
+ import yaml
139
+ home = _omega_home(omega_home)
140
+ path = home / "Agentik_SSOT" / "mcp" / "mcp-catalog.yaml"
141
+ if not path.exists():
142
+ raise CatalogError(f"MCP catalog missing: {path}")
143
+ data = yaml.safe_load(path.read_text()) or {}
144
+ return data
145
+
146
+
147
+ def catalog_entry(catalog: dict[str, Any], mcp_id: str) -> dict[str, Any]:
148
+ for entry in catalog.get("catalog") or []:
149
+ if str(entry.get("id")) == mcp_id:
150
+ return entry
151
+ raise CatalogError(f"unknown MCP id: {mcp_id}")
152
+
153
+
154
+ def install_from_catalog(
155
+ omega_home: str | Path,
156
+ mcp_id: str,
157
+ catalog: dict[str, Any] | None = None,
158
+ registry: ToolRegistry | None = None,
159
+ ) -> Tool:
160
+ """Install one MCP server from the catalog.
161
+
162
+ Concrete steps for `install.method == npx`:
163
+ 1. resolve the npm package from `install.package`
164
+ 2. `npm install -g --prefix Agentik_Tools/<id>/ <package>` — binaries land
165
+ under `Agentik_Tools/<id>/bin/`
166
+ 3. register the tool in registry.json with `source=mcp:<id>`,
167
+ `invoke=Agentik_Tools/<id>/bin/<binname>` (first binary in bin/), and
168
+ `secret_refs` copied from the catalog entry.
169
+
170
+ Other install methods are recorded but logged as "no installer wired" — they
171
+ can be added later without changing the call sites.
172
+ """
173
+ home = Path(omega_home)
174
+ if catalog is None:
175
+ catalog = load_catalog(home)
176
+ entry = catalog_entry(catalog, mcp_id)
177
+
178
+ install = entry.get("install") or {}
179
+ method = str(install.get("method", "")).lower()
180
+ package = str(install.get("package", "")).strip()
181
+ secret_refs = list(entry.get("secrets") or [])
182
+
183
+ tool_dir = home / "Agentik_Tools" / mcp_id
184
+ tool_dir.mkdir(parents=True, exist_ok=True)
185
+ bin_dir = tool_dir / "bin"
186
+
187
+ invoke = ""
188
+ version = ""
189
+
190
+ if method == "npx" and package:
191
+ npm = shutil.which("npm")
192
+ if npm is None:
193
+ raise RuntimeError("npm not found on PATH — install Node.js to use MCP servers")
194
+ # npm install -g --prefix <dir> <pkg> places binaries in <dir>/bin/.
195
+ try:
196
+ subprocess.run(
197
+ [npm, "install", "-g", "--prefix", str(tool_dir), package],
198
+ check=True, capture_output=True, text=True, timeout=600,
199
+ )
200
+ except subprocess.CalledProcessError as exc:
201
+ raise RuntimeError(
202
+ f"npm install failed for {package}: "
203
+ f"{(exc.stderr or exc.stdout or '')[:500]}"
204
+ ) from exc
205
+ if bin_dir.is_dir():
206
+ bins = sorted(p for p in bin_dir.iterdir() if p.is_file())
207
+ if bins:
208
+ invoke = str(bins[0].relative_to(home))
209
+ # best-effort version detect: read the installed package's package.json
210
+ try:
211
+ pkg_dir = tool_dir / "lib" / "node_modules" / package
212
+ pkg_json = pkg_dir / "package.json"
213
+ if pkg_json.exists():
214
+ version = str(json.loads(pkg_json.read_text()).get("version", ""))
215
+ except (OSError, json.JSONDecodeError):
216
+ version = ""
217
+
218
+ tool = Tool(
219
+ name=mcp_id,
220
+ version=version,
221
+ path=f"Agentik_Tools/{mcp_id}/",
222
+ invoke=invoke,
223
+ source=f"mcp:{mcp_id}" if method else f"catalog:{mcp_id}",
224
+ secret_refs=secret_refs,
225
+ installed_at=time.strftime("%Y-%m-%dT%H:%M:%S"),
226
+ )
227
+
228
+ reg = registry if registry is not None else ToolRegistry.load(home)
229
+ reg.install(tool)
230
+ reg.save(home)
231
+ return tool
232
+
233
+
234
+ # --------------------------------------------------------------------------
235
+ # Canonical MCP config (Agentik_SSOT/mcp/mcp-config.yaml)
236
+ # --------------------------------------------------------------------------
237
+
238
+
239
+ def mcp_config_path(omega_home: str | Path | None = None) -> Path:
240
+ return _omega_home(omega_home) / "Agentik_SSOT" / "mcp" / "mcp-config.yaml"
241
+
242
+
243
+ def load_mcp_config(omega_home: str | Path | None = None) -> dict[str, Any]:
244
+ import yaml
245
+ path = mcp_config_path(omega_home)
246
+ if not path.exists():
247
+ return {"version": 1, "servers": []}
248
+ data = yaml.safe_load(path.read_text()) or {}
249
+ data.setdefault("version", 1)
250
+ data.setdefault("servers", [])
251
+ return data
252
+
253
+
254
+ def merge_mcp_config(
255
+ omega_home: str | Path | None,
256
+ entry: dict[str, Any],
257
+ ) -> dict[str, Any]:
258
+ """Append-or-replace one server entry in mcp-config.yaml. Idempotent."""
259
+ import yaml
260
+ data = load_mcp_config(omega_home)
261
+ servers = data.setdefault("servers", [])
262
+ eid = entry.get("id")
263
+ for i, existing in enumerate(servers):
264
+ if existing.get("id") == eid:
265
+ servers[i] = entry
266
+ break
267
+ else:
268
+ servers.append(entry)
269
+ path = mcp_config_path(omega_home)
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ path.write_text(yaml.safe_dump(data, sort_keys=False))
272
+ return data
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omega-engine"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "The Omega OS orchestration engine — event-sourced, verified-completion agent graphs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,333 @@
1
+ """The account pool + vault + billing aggregator — the real things.
2
+
3
+ End-to-end coverage in stdlib:
4
+ - load a YAML pool from a tempdir-based OMEGA_HOME
5
+ - round-robin and least-used selection
6
+ - read_token / write_token round-trip via the vault (mode 600 enforced)
7
+ - BillingAggregator aggregates per-account totals from seeded events
8
+ - the new providers (GLM, OpenAI, DeepSeek) import cleanly with no API key
9
+ and only raise on `.run()` — with a helpful message
10
+
11
+ Standalone: python3 tests/test_account.py
12
+ """
13
+ import os
14
+ import stat
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
20
+
21
+ from omega_engine.account import ( # noqa: E402
22
+ AccountPool,
23
+ BillingAggregator,
24
+ ClaudeAccount,
25
+ read_token,
26
+ vault_path,
27
+ write_token,
28
+ )
29
+ from omega_engine.events import Event, EventType # noqa: E402
30
+ from omega_engine.provider import ( # noqa: E402
31
+ AgentRequest,
32
+ DeepSeekProvider,
33
+ GLMProvider,
34
+ OpenAIProvider,
35
+ )
36
+ from omega_engine.store import SQLiteStore # noqa: E402
37
+
38
+
39
+ # ──── helpers ────
40
+
41
+
42
+ def _omega_home_with_pool(tmp: Path, selection: str = "least-used") -> Path:
43
+ """Seed OMEGA_HOME with an accounts.yaml containing two entries."""
44
+ cfg_dir = tmp / "Agentik_Providers" / "claude"
45
+ cfg_dir.mkdir(parents=True)
46
+ (cfg_dir / "accounts.yaml").write_text(
47
+ f"""version: 1
48
+ selection: {selection}
49
+ pool:
50
+ - id: max-primary
51
+ label: "Primary"
52
+ secret_ref: CLAUDE_OAUTH_max-primary
53
+ weight: 1
54
+ status: active
55
+ - id: max-secondary
56
+ label: "Secondary"
57
+ secret_ref: CLAUDE_OAUTH_max-secondary
58
+ weight: 2
59
+ status: active
60
+ """
61
+ )
62
+ return tmp
63
+
64
+
65
+ # ──── tests ────
66
+
67
+
68
+ def test_pool_load_yaml_two_accounts():
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ home = _omega_home_with_pool(Path(tmp))
71
+ pool = AccountPool.load(home)
72
+ assert len(pool.accounts) == 2, f"want 2, got {len(pool.accounts)}"
73
+ assert {a.id for a in pool.accounts} == {"max-primary", "max-secondary"}
74
+ assert pool.get("max-primary").label == "Primary"
75
+ assert pool.get("max-secondary").weight == 2
76
+
77
+
78
+ def test_pool_falls_back_to_example_when_no_yaml():
79
+ # an OMEGA_HOME with only the example file should still load (the example
80
+ # is the template the repo ships with).
81
+ with tempfile.TemporaryDirectory() as tmp:
82
+ cfg = Path(tmp) / "Agentik_Providers" / "claude"
83
+ cfg.mkdir(parents=True)
84
+ (cfg / "accounts.example.yaml").write_text(
85
+ "version: 1\nselection: least-used\npool:\n - id: max-x\n "
86
+ "label: example\n secret_ref: CLAUDE_OAUTH_max-x\n "
87
+ "weight: 1\n status: active\n"
88
+ )
89
+ pool = AccountPool.load(tmp)
90
+ assert len(pool.accounts) == 1
91
+ assert pool.accounts[0].id == "max-x"
92
+
93
+
94
+ def test_pool_empty_when_no_config():
95
+ with tempfile.TemporaryDirectory() as tmp:
96
+ pool = AccountPool.load(tmp)
97
+ assert pool.accounts == []
98
+ # next() on an empty pool must raise — never silently return nothing
99
+ try:
100
+ pool.next()
101
+ except RuntimeError as exc:
102
+ assert "no active" in str(exc)
103
+ else:
104
+ raise AssertionError("expected RuntimeError on empty pool")
105
+
106
+
107
+ def test_round_robin_alternates():
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ home = _omega_home_with_pool(Path(tmp), selection="round-robin")
110
+ pool = AccountPool.load(home)
111
+ picks = [pool.next().id for _ in range(4)]
112
+ # strict alternation regardless of usage counters
113
+ assert picks == [
114
+ "max-primary", "max-secondary", "max-primary", "max-secondary",
115
+ ], picks
116
+
117
+
118
+ def test_least_used_picks_smaller_counter():
119
+ with tempfile.TemporaryDirectory() as tmp:
120
+ home = _omega_home_with_pool(Path(tmp), selection="least-used")
121
+ pool = AccountPool.load(home)
122
+ # bias primary to have more usage; secondary must win.
123
+ pool.usage_for("max-primary", 1000)
124
+ pick = pool.next()
125
+ assert pick.id == "max-secondary"
126
+ # ties: both at the same count → either is acceptable, but
127
+ # consistent picks one and the next call goes to the other.
128
+ pool.usage_for("max-secondary", 1000)
129
+ first = pool.next().id
130
+ second = pool.next().id
131
+ assert {first, second} <= {"max-primary", "max-secondary"}
132
+
133
+
134
+ def test_set_status_rejects_bad_value():
135
+ with tempfile.TemporaryDirectory() as tmp:
136
+ home = _omega_home_with_pool(Path(tmp))
137
+ pool = AccountPool.load(home)
138
+ try:
139
+ pool.set_status("max-primary", "frozen")
140
+ except ValueError as exc:
141
+ assert "active" in str(exc)
142
+ else:
143
+ raise AssertionError("expected ValueError on bad status")
144
+ # but legal values work and persist through save/load
145
+ pool.set_status("max-primary", "resting")
146
+ pool.save(home)
147
+ reloaded = AccountPool.load(home)
148
+ assert reloaded.get("max-primary").status == "resting"
149
+
150
+
151
+ def test_disabled_account_excluded_from_rotation():
152
+ with tempfile.TemporaryDirectory() as tmp:
153
+ home = _omega_home_with_pool(Path(tmp), selection="round-robin")
154
+ pool = AccountPool.load(home)
155
+ pool.set_status("max-secondary", "disabled")
156
+ picks = [pool.next().id for _ in range(3)]
157
+ assert all(p == "max-primary" for p in picks), picks
158
+
159
+
160
+ def test_vault_round_trip_with_mode_600():
161
+ with tempfile.TemporaryDirectory() as tmp:
162
+ secret = "CLAUDE_OAUTH_max-primary"
163
+ path = write_token(tmp, secret, "sk-test-12345")
164
+ # exact file mode must be 0o600 — nothing else should be readable
165
+ mode = stat.S_IMODE(path.stat().st_mode)
166
+ assert mode == 0o600, f"want 0o600, got {oct(mode)}"
167
+ # round-trip
168
+ token = read_token(tmp, secret)
169
+ assert token == "sk-test-12345"
170
+ # vault_path resolves to the same path
171
+ assert vault_path(tmp, secret) == path
172
+
173
+
174
+ def test_vault_refuses_path_traversal():
175
+ with tempfile.TemporaryDirectory() as tmp:
176
+ for bad in ("../escape", "a/b", "..", ""):
177
+ try:
178
+ vault_path(tmp, bad)
179
+ except ValueError:
180
+ continue
181
+ raise AssertionError(f"vault_path accepted bad ref: {bad!r}")
182
+
183
+
184
+ def test_vault_read_missing_raises():
185
+ with tempfile.TemporaryDirectory() as tmp:
186
+ try:
187
+ read_token(tmp, "CLAUDE_OAUTH_nonexistent")
188
+ except FileNotFoundError:
189
+ return
190
+ raise AssertionError("expected FileNotFoundError on missing vault file")
191
+
192
+
193
+ def test_billing_aggregates_per_account_from_events():
194
+ """Seed a real SQLite event store with task.* events that carry
195
+ `usage.account_id`, then verify BillingAggregator sums them correctly."""
196
+ with tempfile.TemporaryDirectory() as tmp:
197
+ db = Path(tmp) / "omega.db"
198
+ store = SQLiteStore(db)
199
+ # Two accounts billed across three task events. One event with no
200
+ # usage payload must be ignored (negative test in the same pass).
201
+ store.append(Event(
202
+ task_id="t-1", type=EventType.COMPLETED,
203
+ payload={"usage": {"account_id": "max-primary",
204
+ "input_tokens": 100, "output_tokens": 200}},
205
+ ))
206
+ store.append(Event(
207
+ task_id="t-2", type=EventType.COMPLETED,
208
+ payload={"usage": {"account_id": "max-primary",
209
+ "input_tokens": 50, "output_tokens": 75}},
210
+ ))
211
+ store.append(Event(
212
+ task_id="t-3", type=EventType.COMPLETED,
213
+ payload={"usage": {"account_id": "max-secondary",
214
+ "input_tokens": 10, "output_tokens": 20}},
215
+ ))
216
+ # noise: an event with no usage payload — must NOT be counted
217
+ store.append(Event(
218
+ task_id="t-4", type=EventType.HEARTBEAT, payload={},
219
+ ))
220
+ # noise: an event with usage but zero tokens — must NOT be counted
221
+ store.append(Event(
222
+ task_id="t-5", type=EventType.COMPLETED,
223
+ payload={"usage": {"account_id": "max-secondary",
224
+ "input_tokens": 0, "output_tokens": 0}},
225
+ ))
226
+ totals = BillingAggregator.from_event_log(store)
227
+ assert set(totals.keys()) == {"max-primary", "max-secondary"}, totals
228
+ assert totals["max-primary"] == {
229
+ "input_tokens": 150, "output_tokens": 275, "total_calls": 2,
230
+ }
231
+ assert totals["max-secondary"] == {
232
+ "input_tokens": 10, "output_tokens": 20, "total_calls": 1,
233
+ }
234
+ store.close()
235
+
236
+
237
+ def test_billing_empty_log_returns_empty_dict():
238
+ with tempfile.TemporaryDirectory() as tmp:
239
+ db = Path(tmp) / "omega.db"
240
+ store = SQLiteStore(db)
241
+ totals = BillingAggregator.from_event_log(store)
242
+ assert totals == {}
243
+ store.close()
244
+
245
+
246
+ def test_new_providers_import_without_api_key():
247
+ """The three new providers must be constructible with no env, no args.
248
+ Errors are deferred to `.run()` so the rest of the engine can keep
249
+ talking about them as objects."""
250
+ # cover the no-arg path explicitly
251
+ for cls in (GLMProvider, OpenAIProvider, DeepSeekProvider):
252
+ p = cls()
253
+ assert isinstance(p.id, str) and p.id
254
+ # default model is set
255
+ assert p._model, f"{cls.__name__} default model not set"
256
+ # custom args also accepted
257
+ p2 = cls(model="custom", api_key=None, base_url="https://example.test")
258
+ assert p2._model == "custom"
259
+ assert p2._base_url == "https://example.test"
260
+
261
+
262
+ def _expect_runtime_error_on_run(provider, env_var: str) -> None:
263
+ """Calling .run() with no key in env AND no api_key arg must raise
264
+ a RuntimeError whose message names the missing env var."""
265
+ saved = os.environ.pop(env_var, None)
266
+ try:
267
+ provider.run(AgentRequest(role="worker", prompt="hi"))
268
+ except RuntimeError as exc:
269
+ msg = str(exc)
270
+ assert env_var in msg, f"{env_var} not in error message: {msg!r}"
271
+ return
272
+ finally:
273
+ if saved is not None:
274
+ os.environ[env_var] = saved
275
+ raise AssertionError(
276
+ f"{type(provider).__name__}.run() did not raise on missing key"
277
+ )
278
+
279
+
280
+ def test_glm_provider_raises_without_key():
281
+ _expect_runtime_error_on_run(GLMProvider(), "GLM_API_KEY")
282
+
283
+
284
+ def test_openai_provider_raises_without_key():
285
+ _expect_runtime_error_on_run(OpenAIProvider(), "OPENAI_API_KEY")
286
+
287
+
288
+ def test_deepseek_provider_raises_without_key():
289
+ _expect_runtime_error_on_run(DeepSeekProvider(), "DEEPSEEK_API_KEY")
290
+
291
+
292
+ def test_account_dataclass_round_trip_yaml():
293
+ """ClaudeAccount.to_yaml -> AccountPool.save -> AccountPool.load must
294
+ preserve all the fields we serialize."""
295
+ with tempfile.TemporaryDirectory() as tmp:
296
+ pool = AccountPool(
297
+ accounts=[
298
+ ClaudeAccount(
299
+ id="max-new", label="round trip", secret_ref="CLAUDE_OAUTH_max-new",
300
+ weight=3, status="resting", tokens_used=1234, last_used_at=1700.0,
301
+ ),
302
+ ],
303
+ selection="round-robin",
304
+ )
305
+ cfg = pool.save(tmp)
306
+ assert cfg.exists()
307
+ reloaded = AccountPool.load(tmp)
308
+ assert reloaded.selection == "round-robin"
309
+ a = reloaded.get("max-new")
310
+ assert a is not None
311
+ assert a.weight == 3
312
+ assert a.status == "resting"
313
+ assert a.tokens_used == 1234
314
+ assert a.last_used_at == 1700.0
315
+
316
+
317
+ # ──── runner ────
318
+
319
+
320
+ def _run_all() -> bool:
321
+ tests = [v for k, v in sorted(globals().items())
322
+ if k.startswith("test_") and callable(v)]
323
+ passed = 0
324
+ for t in tests:
325
+ t()
326
+ print(f" PASS {t.__name__}")
327
+ passed += 1
328
+ print(f"\n{passed}/{len(tests)} account tests passed")
329
+ return passed == len(tests)
330
+
331
+
332
+ if __name__ == "__main__":
333
+ sys.exit(0 if _run_all() else 1)