@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.
- package/README.md +25 -13
- package/bootstrap/lib/steps.sh +214 -9
- package/bootstrap/manifest.example.yaml +6 -1
- package/docs/COMPLETION-PLAN.md +48 -0
- package/omega/Agentik_Engine/README.md +25 -10
- package/omega/Agentik_Engine/omega_engine/__init__.py +66 -2
- package/omega/Agentik_Engine/omega_engine/account.py +505 -0
- package/omega/Agentik_Engine/omega_engine/autonomous.py +538 -0
- package/omega/Agentik_Engine/omega_engine/cli.py +467 -29
- package/omega/Agentik_Engine/omega_engine/daemons/__init__.py +14 -0
- package/omega/Agentik_Engine/omega_engine/daemons/autonomous.py +56 -0
- package/omega/Agentik_Engine/omega_engine/daemons/engine.py +187 -0
- package/omega/Agentik_Engine/omega_engine/daemons/telegram.py +231 -0
- package/omega/Agentik_Engine/omega_engine/educators/__init__.py +51 -0
- package/omega/Agentik_Engine/omega_engine/educators/artifact.py +65 -0
- package/omega/Agentik_Engine/omega_engine/educators/automation.py +76 -0
- package/omega/Agentik_Engine/omega_engine/educators/base.py +327 -0
- package/omega/Agentik_Engine/omega_engine/educators/claudecode.py +71 -0
- package/omega/Agentik_Engine/omega_engine/educators/connection.py +75 -0
- package/omega/Agentik_Engine/omega_engine/educators/coworker.py +68 -0
- package/omega/Agentik_Engine/omega_engine/educators/loop.py +82 -0
- package/omega/Agentik_Engine/omega_engine/educators/prompt.py +68 -0
- package/omega/Agentik_Engine/omega_engine/educators/skill.py +69 -0
- package/omega/Agentik_Engine/omega_engine/executor.py +46 -6
- package/omega/Agentik_Engine/omega_engine/mission.py +13 -1
- package/omega/Agentik_Engine/omega_engine/provider.py +247 -1
- package/omega/Agentik_Engine/omega_engine/rag/__init__.py +21 -0
- package/omega/Agentik_Engine/omega_engine/rag/agentic.py +83 -0
- package/omega/Agentik_Engine/omega_engine/rag/base.py +42 -0
- package/omega/Agentik_Engine/omega_engine/rag/corrective.py +119 -0
- package/omega/Agentik_Engine/omega_engine/rag/graph.py +169 -0
- package/omega/Agentik_Engine/omega_engine/rag/hybrid.py +205 -0
- package/omega/Agentik_Engine/omega_engine/rag/multimodal.py +136 -0
- package/omega/Agentik_Engine/omega_engine/rag/router.py +110 -0
- package/omega/Agentik_Engine/omega_engine/reducer.py +21 -3
- package/omega/Agentik_Engine/omega_engine/store.py +65 -5
- package/omega/Agentik_Engine/omega_engine/sync.py +304 -0
- package/omega/Agentik_Engine/omega_engine/tools.py +272 -0
- package/omega/Agentik_Engine/pyproject.toml +1 -1
- package/omega/Agentik_Engine/tests/test_account.py +333 -0
- package/omega/Agentik_Engine/tests/test_autonomous.py +361 -0
- package/omega/Agentik_Engine/tests/test_educators.py +233 -0
- package/omega/Agentik_Engine/tests/test_rag.py +287 -0
- package/omega/Agentik_Engine/tests/test_snapshot_partial.py +172 -0
- package/omega/Agentik_Engine/tests/test_tools_and_sync.py +312 -0
- package/omega/Agentik_SSOT/skills/rag-route.md +73 -0
- package/package.json +1 -1
- package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/audit.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/audit_arsenal.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/barrier.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/bus.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/events.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/executor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/mission.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/progress.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/project.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/provider.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/reducer.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/report.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/router.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/store.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/supervisor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/task.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/omega_engine/__pycache__/telegram.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_audit_arsenal.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_executor.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_mission.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_progress.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_project.cpython-313.pyc +0 -0
- package/omega/Agentik_Engine/tests/__pycache__/test_reducer.cpython-313.pyc +0 -0
- 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
|
|
@@ -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)
|