@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,361 @@
|
|
|
1
|
+
"""Autonomous-agent supervisor — charters, cron parser, and trigger firing.
|
|
2
|
+
|
|
3
|
+
Standalone runner: ``python3 tests/test_autonomous.py``
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
12
|
+
|
|
13
|
+
from omega_engine.autonomous import ( # noqa: E402
|
|
14
|
+
AutonomousSupervisor,
|
|
15
|
+
Charter,
|
|
16
|
+
load_charters,
|
|
17
|
+
next_fire,
|
|
18
|
+
parse_cron,
|
|
19
|
+
)
|
|
20
|
+
from omega_engine.events import Event, EventType # noqa: E402
|
|
21
|
+
from omega_engine.task import Lifecycle # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
# fixture: a directory of example charters, identical in spirit to the file
|
|
26
|
+
# shipped at Agentik_Orchestration/autonomous/example-agents.yaml
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
_EXAMPLE_YAML = """\
|
|
30
|
+
# autonomous-agent charters — mirror of example-agents.yaml
|
|
31
|
+
template:
|
|
32
|
+
id: <agent-id>
|
|
33
|
+
role: <role-name>
|
|
34
|
+
lifecycle: persistent
|
|
35
|
+
charter: >
|
|
36
|
+
Placeholder.
|
|
37
|
+
trigger:
|
|
38
|
+
type: <cron|event|webhook|channel>
|
|
39
|
+
config: {}
|
|
40
|
+
channel:
|
|
41
|
+
telegram_topic: 0
|
|
42
|
+
budget:
|
|
43
|
+
max_iterations: 5
|
|
44
|
+
heartbeat_interval_s: 300
|
|
45
|
+
|
|
46
|
+
support-agent:
|
|
47
|
+
id: support-agent
|
|
48
|
+
role: customer-support
|
|
49
|
+
lifecycle: persistent
|
|
50
|
+
charter: >
|
|
51
|
+
Watch the support channel. Triage every inbound message.
|
|
52
|
+
trigger:
|
|
53
|
+
type: channel
|
|
54
|
+
config: { telegram_topic: 4012 }
|
|
55
|
+
channel:
|
|
56
|
+
telegram_topic: 4012
|
|
57
|
+
budget:
|
|
58
|
+
max_iterations: 5
|
|
59
|
+
heartbeat_interval_s: 300
|
|
60
|
+
provider: glm
|
|
61
|
+
allowed:
|
|
62
|
+
skills: [classify-intent, draft-reply, rag-route]
|
|
63
|
+
mcp: [filesystem, github, linear]
|
|
64
|
+
topologies: [aisb-oracle-worker]
|
|
65
|
+
guardrails:
|
|
66
|
+
may_spawn_missions: true
|
|
67
|
+
may_ship: false
|
|
68
|
+
|
|
69
|
+
growth-agent:
|
|
70
|
+
id: growth-agent
|
|
71
|
+
role: growth
|
|
72
|
+
lifecycle: persistent
|
|
73
|
+
charter: >
|
|
74
|
+
Every morning, pull yesterday's product metrics and open one mission.
|
|
75
|
+
trigger:
|
|
76
|
+
type: cron
|
|
77
|
+
config: { schedule: "0 7 * * *" }
|
|
78
|
+
channel:
|
|
79
|
+
telegram_topic: 4020
|
|
80
|
+
budget:
|
|
81
|
+
max_iterations: 3
|
|
82
|
+
heartbeat_interval_s: 600
|
|
83
|
+
provider: claude
|
|
84
|
+
allowed:
|
|
85
|
+
skills: [rag-route, metrics-read, prioritize]
|
|
86
|
+
mcp: [filesystem, postgres, github]
|
|
87
|
+
topologies: [aisb-oracle-worker]
|
|
88
|
+
guardrails:
|
|
89
|
+
may_spawn_missions: true
|
|
90
|
+
may_ship: false
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
_EVENT_AGENT_YAML = """\
|
|
94
|
+
fix-on-fail:
|
|
95
|
+
id: fix-on-fail
|
|
96
|
+
role: triage
|
|
97
|
+
lifecycle: persistent
|
|
98
|
+
charter: >
|
|
99
|
+
React to every task.failed.
|
|
100
|
+
trigger:
|
|
101
|
+
type: event
|
|
102
|
+
config: { event_type: "task.failed" }
|
|
103
|
+
channel:
|
|
104
|
+
telegram_topic: 5000
|
|
105
|
+
budget:
|
|
106
|
+
max_iterations: 1
|
|
107
|
+
heartbeat_interval_s: 60
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_WEBHOOK_YAML = """\
|
|
111
|
+
ci-hook:
|
|
112
|
+
id: ci-hook
|
|
113
|
+
role: ci
|
|
114
|
+
lifecycle: persistent
|
|
115
|
+
charter: >
|
|
116
|
+
Run on every push.
|
|
117
|
+
trigger:
|
|
118
|
+
type: webhook
|
|
119
|
+
config: { webhook_path: /hooks/push }
|
|
120
|
+
channel:
|
|
121
|
+
telegram_topic: 6000
|
|
122
|
+
budget:
|
|
123
|
+
max_iterations: 1
|
|
124
|
+
heartbeat_interval_s: 60
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _charter_dir() -> Path:
|
|
129
|
+
d = Path(tempfile.mkdtemp(prefix="omega-charters-"))
|
|
130
|
+
(d / "example-agents.yaml").write_text(_EXAMPLE_YAML)
|
|
131
|
+
(d / "fix-on-fail.yaml").write_text(_EVENT_AGENT_YAML)
|
|
132
|
+
(d / "ci-hook.yaml").write_text(_WEBHOOK_YAML)
|
|
133
|
+
return d
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
# tests
|
|
138
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def test_load_charters_skips_template_block_and_loads_real_charters():
|
|
141
|
+
d = _charter_dir()
|
|
142
|
+
charters = load_charters(d)
|
|
143
|
+
ids = sorted(c.id for c in charters)
|
|
144
|
+
# the `template:` placeholder must NOT come through as a charter; the four
|
|
145
|
+
# real ones (support, growth, event, webhook) must.
|
|
146
|
+
assert ids == ["ci-hook", "fix-on-fail", "growth-agent", "support-agent"], ids
|
|
147
|
+
# every charter is a real Charter dataclass with the right fields populated
|
|
148
|
+
for c in charters:
|
|
149
|
+
assert isinstance(c, Charter)
|
|
150
|
+
assert c.lifecycle == Lifecycle.PERSISTENT.value
|
|
151
|
+
assert c.trigger_type in ("cron", "event", "webhook", "channel")
|
|
152
|
+
assert c.source_file and c.source_file.endswith(".yaml")
|
|
153
|
+
# spot-check the support charter mirrors the YAML
|
|
154
|
+
support = next(c for c in charters if c.id == "support-agent")
|
|
155
|
+
assert support.role == "customer-support"
|
|
156
|
+
assert support.trigger_type == "channel"
|
|
157
|
+
assert support.trigger_config == {"telegram_topic": 4012}
|
|
158
|
+
assert support.channel_topic == 4012
|
|
159
|
+
assert support.max_iterations == 5
|
|
160
|
+
assert support.heartbeat_interval_s == 300
|
|
161
|
+
assert support.provider == "glm"
|
|
162
|
+
assert "classify-intent" in support.allowed_skills
|
|
163
|
+
assert "linear" in support.allowed_mcp
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_load_charters_handles_missing_or_empty_dir():
|
|
167
|
+
# missing directory → empty list, no exception
|
|
168
|
+
assert load_charters(Path("/tmp/this/does/not/exist")) == []
|
|
169
|
+
# empty directory → empty list
|
|
170
|
+
empty = Path(tempfile.mkdtemp())
|
|
171
|
+
assert load_charters(empty) == []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_parse_cron_basic_forms():
|
|
175
|
+
# the exact form used by growth-agent
|
|
176
|
+
minute, hour, dom, month, dow = parse_cron("0 7 * * *")
|
|
177
|
+
assert minute == {0}
|
|
178
|
+
assert hour == {7}
|
|
179
|
+
assert dom == set(range(1, 32))
|
|
180
|
+
assert month == set(range(1, 13))
|
|
181
|
+
assert dow == set(range(0, 7))
|
|
182
|
+
|
|
183
|
+
# */N step on every field
|
|
184
|
+
minute, *_ = parse_cron("*/5 * * * *")
|
|
185
|
+
assert minute == {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
|
|
186
|
+
|
|
187
|
+
# comma lists + ranges
|
|
188
|
+
minute, hour, *_ = parse_cron("0,15,30,45 9-17 * * 1-5")
|
|
189
|
+
assert minute == {0, 15, 30, 45}
|
|
190
|
+
assert hour == set(range(9, 18))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_next_fire_every_five_minutes_returns_now_plus_about_5_min():
|
|
194
|
+
now = time.time()
|
|
195
|
+
nf = next_fire("*/5 * * * *", now)
|
|
196
|
+
# the next */5 boundary is at most 5min away (300s); the search rounds up to
|
|
197
|
+
# the next whole minute, so the legal lower bound is 0s, upper bound 300s.
|
|
198
|
+
delta = nf - now
|
|
199
|
+
assert 0 < delta <= 300, f"delta={delta}"
|
|
200
|
+
# and the returned timestamp must actually be on a 5-minute boundary
|
|
201
|
+
assert time.localtime(nf).tm_min % 5 == 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_next_fire_daily_seven_am_lands_on_seven_am():
|
|
205
|
+
now = time.time()
|
|
206
|
+
nf = next_fire("0 7 * * *", now)
|
|
207
|
+
t = time.localtime(nf)
|
|
208
|
+
assert t.tm_hour == 7 and t.tm_min == 0
|
|
209
|
+
# and it's within the next 24 hours + a small margin
|
|
210
|
+
assert nf > now
|
|
211
|
+
assert nf - now <= 24 * 60 * 60 + 60
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_supervisor_cron_tick_invokes_runner():
|
|
215
|
+
"""The crux: a cron trigger past its `next_fire_at` calls the runner."""
|
|
216
|
+
fired: list[dict] = []
|
|
217
|
+
|
|
218
|
+
def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
|
|
219
|
+
fired.append({
|
|
220
|
+
"intent": intent, "topic_id": topic_id, "charter_id": charter.id,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
d = _charter_dir()
|
|
224
|
+
charters = load_charters(d)
|
|
225
|
+
sup = AutonomousSupervisor(charters, runner=fake_runner)
|
|
226
|
+
|
|
227
|
+
# force the growth-agent's next_fire into the past, then tick
|
|
228
|
+
growth_sched = sup._schedules["growth-agent"] # noqa: SLF001
|
|
229
|
+
growth_sched.next_fire_at = time.time() - 10
|
|
230
|
+
fired_ids = sup.tick(now=time.time())
|
|
231
|
+
|
|
232
|
+
assert "growth-agent" in fired_ids
|
|
233
|
+
assert any(f["charter_id"] == "growth-agent" for f in fired)
|
|
234
|
+
# the runner saw the bound topic id
|
|
235
|
+
growth = [f for f in fired if f["charter_id"] == "growth-agent"][0]
|
|
236
|
+
assert growth["topic_id"] == 4020
|
|
237
|
+
# the schedule rescheduled itself for the next fire — strictly in the future
|
|
238
|
+
assert growth_sched.next_fire_at is not None
|
|
239
|
+
assert growth_sched.next_fire_at > time.time()
|
|
240
|
+
# runs counter incremented; no errors
|
|
241
|
+
assert growth_sched.runs == 1
|
|
242
|
+
assert growth_sched.errors == 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_supervisor_channel_trigger_dispatches_on_inbound_message():
|
|
246
|
+
fired: list[dict] = []
|
|
247
|
+
|
|
248
|
+
def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
|
|
249
|
+
fired.append({"charter": charter.id, "intent": intent, "topic": topic_id})
|
|
250
|
+
|
|
251
|
+
charters = load_charters(_charter_dir())
|
|
252
|
+
sup = AutonomousSupervisor(charters, runner=fake_runner)
|
|
253
|
+
# wrong topic → nothing
|
|
254
|
+
assert sup.on_channel_message(99999, "ignored") == []
|
|
255
|
+
assert fired == []
|
|
256
|
+
# correct topic → support-agent fires with the message as the intent
|
|
257
|
+
out = sup.on_channel_message(4012, "the printer is on fire")
|
|
258
|
+
assert out == ["support-agent"]
|
|
259
|
+
assert fired == [{"charter": "support-agent",
|
|
260
|
+
"intent": "the printer is on fire",
|
|
261
|
+
"topic": 4012}]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_supervisor_webhook_trigger_fires_on_matching_path():
|
|
265
|
+
fired: list[str] = []
|
|
266
|
+
|
|
267
|
+
def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
|
|
268
|
+
fired.append(charter.id)
|
|
269
|
+
|
|
270
|
+
charters = load_charters(_charter_dir())
|
|
271
|
+
sup = AutonomousSupervisor(charters, runner=fake_runner)
|
|
272
|
+
# non-matching path → nothing
|
|
273
|
+
assert sup.wake_webhook("/hooks/unknown", {}) == []
|
|
274
|
+
assert fired == []
|
|
275
|
+
# matching path → ci-hook fires
|
|
276
|
+
assert sup.wake_webhook("/hooks/push", {"intent": "deploy"}) == ["ci-hook"]
|
|
277
|
+
assert fired == ["ci-hook"]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_supervisor_one_bad_charter_does_not_kill_the_others():
|
|
281
|
+
"""A runner that raises must be caught — the supervisor never dies."""
|
|
282
|
+
fired: list[str] = []
|
|
283
|
+
|
|
284
|
+
def runner(*, intent, omega_home, topic_id, telegram, charter):
|
|
285
|
+
if charter.id == "growth-agent":
|
|
286
|
+
raise RuntimeError("boom")
|
|
287
|
+
fired.append(charter.id)
|
|
288
|
+
|
|
289
|
+
sup = AutonomousSupervisor(load_charters(_charter_dir()), runner=runner)
|
|
290
|
+
# force both an immediate-fire cron AND a channel-fire on the same tick
|
|
291
|
+
sup._schedules["growth-agent"].next_fire_at = time.time() - 10 # noqa: SLF001
|
|
292
|
+
sup.tick()
|
|
293
|
+
sup.on_channel_message(4012, "support please")
|
|
294
|
+
# the channel charter ran despite the crash inside the cron charter
|
|
295
|
+
assert fired == ["support-agent"]
|
|
296
|
+
growth_stats = sup.stats()["growth-agent"]
|
|
297
|
+
assert growth_stats["errors"] >= 1
|
|
298
|
+
assert growth_stats["last_error"].startswith("RuntimeError:")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_supervisor_event_trigger_via_bus():
|
|
302
|
+
"""An event trigger fires when a matching event hits the bus."""
|
|
303
|
+
fired: list[str] = []
|
|
304
|
+
|
|
305
|
+
def runner(*, intent, omega_home, topic_id, telegram, charter):
|
|
306
|
+
fired.append(charter.id)
|
|
307
|
+
|
|
308
|
+
class _FakeBus:
|
|
309
|
+
def __init__(self):
|
|
310
|
+
self.subs = []
|
|
311
|
+
def subscribe(self, fn):
|
|
312
|
+
self.subs.append(fn)
|
|
313
|
+
def publish(self, event):
|
|
314
|
+
for fn in self.subs:
|
|
315
|
+
fn(event)
|
|
316
|
+
|
|
317
|
+
bus = _FakeBus()
|
|
318
|
+
sup = AutonomousSupervisor(
|
|
319
|
+
load_charters(_charter_dir()), bus=bus, runner=runner)
|
|
320
|
+
# publishing a non-matching event does nothing
|
|
321
|
+
bus.publish(Event(task_id="t-1", type=EventType.STARTED))
|
|
322
|
+
assert fired == []
|
|
323
|
+
# publishing task.failed wakes the fix-on-fail charter
|
|
324
|
+
bus.publish(Event(task_id="t-1", type=EventType.FAILED,
|
|
325
|
+
payload={"reason": "test"}))
|
|
326
|
+
assert fired == ["fix-on-fail"]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_supervisor_with_no_charters_still_runs_one_tick_cleanly():
|
|
330
|
+
"""A supervisor with zero charters must not crash on an empty tick."""
|
|
331
|
+
sup = AutonomousSupervisor([], runner=lambda **kw: None)
|
|
332
|
+
assert sup.charters() == []
|
|
333
|
+
assert sup.tick() == []
|
|
334
|
+
assert sup.stats() == {}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
338
|
+
# standalone runner
|
|
339
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def _run_all() -> bool:
|
|
342
|
+
tests = [(k, v) for k, v in sorted(globals().items())
|
|
343
|
+
if k.startswith("test_") and callable(v)]
|
|
344
|
+
passed = 0
|
|
345
|
+
for name, fn in tests:
|
|
346
|
+
try:
|
|
347
|
+
fn()
|
|
348
|
+
except AssertionError as exc:
|
|
349
|
+
print(f" FAIL {name}: {exc}")
|
|
350
|
+
continue
|
|
351
|
+
except Exception as exc: # noqa: BLE001
|
|
352
|
+
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
|
353
|
+
continue
|
|
354
|
+
print(f" PASS {name}")
|
|
355
|
+
passed += 1
|
|
356
|
+
print(f"\n{passed}/{len(tests)} autonomous tests passed")
|
|
357
|
+
return passed == len(tests)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
sys.exit(0 if _run_all() else 1)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""The 8 educators — generators that produce SSOT artifacts under a quality gate.
|
|
2
|
+
|
|
3
|
+
Each educator runs `.generate()` against the `MockProvider` and must return a
|
|
4
|
+
valid `EducatorProposal` whose artifact targets the right SSOT path. The
|
|
5
|
+
`StagingPipeline` then writes the proposal to a temp dir (the real one is
|
|
6
|
+
`Agentik_Extra/staging/promotion/`).
|
|
7
|
+
|
|
8
|
+
Standalone: python3 tests/test_educators.py
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
17
|
+
|
|
18
|
+
from omega_engine.educators import ( # noqa: E402
|
|
19
|
+
ArtifactEducator,
|
|
20
|
+
AutomationEducator,
|
|
21
|
+
ClaudecodeEducator,
|
|
22
|
+
ConnectionEducator,
|
|
23
|
+
CoworkerEducator,
|
|
24
|
+
EducatorProposal,
|
|
25
|
+
EducatorRegistry,
|
|
26
|
+
LoopEducator,
|
|
27
|
+
PromptEducator,
|
|
28
|
+
SkillEducator,
|
|
29
|
+
StagingPipeline,
|
|
30
|
+
)
|
|
31
|
+
from omega_engine.provider import MockProvider # noqa: E402
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------
|
|
35
|
+
# A table of (educator class, generation intent, context, expected path prefix)
|
|
36
|
+
# — one row per educator. Drives every per-educator test.
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
EDUCATOR_CASES = [
|
|
40
|
+
(
|
|
41
|
+
PromptEducator,
|
|
42
|
+
"oracle dispatches a fresh worker for a Linear feedback fix",
|
|
43
|
+
{"source": "oracle", "target": "worker"},
|
|
44
|
+
"Agentik_SSOT/prompts/oracle-to-worker.md",
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
ArtifactEducator,
|
|
48
|
+
"a Linear ticket mission report after the worker ships",
|
|
49
|
+
{"artifact_kind": "mission-report"},
|
|
50
|
+
"Agentik_SSOT/templates/mission-report.md",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
SkillEducator,
|
|
54
|
+
"a /releasenotes skill that summarises the last 7 days of merged PRs",
|
|
55
|
+
{"skill_name": "releasenotes"},
|
|
56
|
+
"Agentik_SSOT/skills/releasenotes.md",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
CoworkerEducator,
|
|
60
|
+
"a react-specialist role focused on Next.js app-router work",
|
|
61
|
+
{"role_name": "react-specialist"},
|
|
62
|
+
"Agentik_SSOT/agents/react-specialist.md",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
ConnectionEducator,
|
|
66
|
+
"an MCP connector for Linear (issues + comments)",
|
|
67
|
+
{"connector_id": "linear-mcp"},
|
|
68
|
+
"Agentik_SSOT/mcp/configs/linear-mcp.yaml",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
AutomationEducator,
|
|
72
|
+
"a daily cron that aggregates yesterday's mission outcomes",
|
|
73
|
+
{"automation_id": "daily-mission-rollup"},
|
|
74
|
+
"Agentik_SSOT/automations/daily-mission-rollup.yaml",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
ClaudecodeEducator,
|
|
78
|
+
"the new native compaction primitive in Claude Code 2.1.155",
|
|
79
|
+
{"change_id": "native-compaction-2-1-155"},
|
|
80
|
+
"Agentik_AI/adapters/claude-code/notes.md",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
LoopEducator,
|
|
84
|
+
"the loop for a single-file UI bug fix — fast, 3 iterations max",
|
|
85
|
+
{"loop_id": "single-file-ui-fix"},
|
|
86
|
+
"Agentik_SSOT/loops/single-file-ui-fix.yaml",
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_registry_holds_the_eight():
|
|
92
|
+
"""Every educator the README lists must be discoverable through the registry."""
|
|
93
|
+
reg = EducatorRegistry.default()
|
|
94
|
+
names = reg.names()
|
|
95
|
+
expected = {"prompt", "artifact", "skill", "coworker",
|
|
96
|
+
"connection", "automation", "claudecode", "loop"}
|
|
97
|
+
assert set(names) == expected, f"registry mismatch: {names}"
|
|
98
|
+
assert len(reg.all()) == 8
|
|
99
|
+
for name in expected:
|
|
100
|
+
e = reg.get(name)
|
|
101
|
+
assert e is not None, f"educator '{name}' missing"
|
|
102
|
+
assert hasattr(e, "generate"), f"'{name}' has no generate()"
|
|
103
|
+
print(f" {len(names)} educators registered: {', '.join(names)}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_each_educator_produces_a_valid_proposal():
|
|
107
|
+
"""Run every educator once with the MockProvider; assert the proposal shape."""
|
|
108
|
+
provider = MockProvider()
|
|
109
|
+
for cls, intent, ctx, expected_path in EDUCATOR_CASES:
|
|
110
|
+
educator = cls()
|
|
111
|
+
proposal = educator.generate(intent, ctx, provider)
|
|
112
|
+
# contract: a real EducatorProposal with a populated artifact
|
|
113
|
+
assert isinstance(proposal, EducatorProposal), \
|
|
114
|
+
f"{cls.__name__} did not return EducatorProposal"
|
|
115
|
+
assert proposal.educator == educator.name
|
|
116
|
+
# artifact body must be non-empty (the educator filled it from the mock)
|
|
117
|
+
assert proposal.artifact.content, \
|
|
118
|
+
f"{cls.__name__} produced empty artifact content"
|
|
119
|
+
# target_path is exactly what this educator advertises
|
|
120
|
+
assert proposal.artifact.target_path == expected_path, (
|
|
121
|
+
f"{cls.__name__}: expected target_path={expected_path}, "
|
|
122
|
+
f"got {proposal.artifact.target_path}"
|
|
123
|
+
)
|
|
124
|
+
# score must be machine-parseable; the mock returns >= 85
|
|
125
|
+
assert 0 <= proposal.score <= 100, \
|
|
126
|
+
f"{cls.__name__}: score out of range ({proposal.score})"
|
|
127
|
+
assert proposal.score >= 85, (
|
|
128
|
+
f"{cls.__name__}: mock score should be >= 85, got {proposal.score}"
|
|
129
|
+
)
|
|
130
|
+
print(f" {len(EDUCATOR_CASES)} educators produced valid proposals")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_staging_pipeline_writes_under_temp_root():
|
|
134
|
+
"""`StagingPipeline.stage` lands proposals under a per-educator bucket."""
|
|
135
|
+
tmp = Path(tempfile.mkdtemp(prefix="omega-staging-"))
|
|
136
|
+
try:
|
|
137
|
+
staging = StagingPipeline(tmp)
|
|
138
|
+
assert staging.root == tmp and tmp.exists()
|
|
139
|
+
|
|
140
|
+
provider = MockProvider()
|
|
141
|
+
records = []
|
|
142
|
+
for cls, intent, ctx, _expected in EDUCATOR_CASES:
|
|
143
|
+
proposal = cls().generate(intent, ctx, provider)
|
|
144
|
+
record = staging.stage(proposal)
|
|
145
|
+
records.append((proposal, record))
|
|
146
|
+
|
|
147
|
+
# one staged artifact + one .json per educator
|
|
148
|
+
listed = staging.list()
|
|
149
|
+
assert len(listed) == len(EDUCATOR_CASES), \
|
|
150
|
+
f"expected {len(EDUCATOR_CASES)} staged proposals, got {len(listed)}"
|
|
151
|
+
|
|
152
|
+
# the on-disk content matches what the educator produced
|
|
153
|
+
for proposal, record in records:
|
|
154
|
+
assert record.artifact_path.exists()
|
|
155
|
+
assert record.proposal_path.exists()
|
|
156
|
+
assert record.target_path == proposal.artifact.target_path
|
|
157
|
+
on_disk = record.artifact_path.read_text()
|
|
158
|
+
assert on_disk == proposal.artifact.content
|
|
159
|
+
meta = json.loads(record.proposal_path.read_text())
|
|
160
|
+
assert meta["educator"] == proposal.educator
|
|
161
|
+
assert meta["artifact"]["target_path"] == proposal.artifact.target_path
|
|
162
|
+
# extension preserved (.md or .yaml)
|
|
163
|
+
expected_suffix = Path(proposal.artifact.target_path).suffix
|
|
164
|
+
assert record.artifact_path.suffix == expected_suffix
|
|
165
|
+
|
|
166
|
+
# clear() removes everything
|
|
167
|
+
removed = staging.clear()
|
|
168
|
+
assert removed >= len(EDUCATOR_CASES) * 2 # artifact + meta per proposal
|
|
169
|
+
assert staging.list() == []
|
|
170
|
+
print(f" staged + cleared {len(EDUCATOR_CASES)} proposals in {tmp.name}")
|
|
171
|
+
finally:
|
|
172
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_educator_never_writes_to_ssot_directly():
|
|
176
|
+
"""An educator returns a proposal — it must NEVER touch the filesystem itself.
|
|
177
|
+
|
|
178
|
+
We assert this structurally: every educator's `.generate()` returns an
|
|
179
|
+
`EducatorProposal` whose `artifact.target_path` is a *string*, not a side
|
|
180
|
+
effect. The only path that ever writes is `StagingPipeline.stage()`.
|
|
181
|
+
"""
|
|
182
|
+
provider = MockProvider()
|
|
183
|
+
for cls, intent, ctx, _expected in EDUCATOR_CASES:
|
|
184
|
+
proposal = cls().generate(intent, ctx, provider)
|
|
185
|
+
target = proposal.artifact.target_path
|
|
186
|
+
# the target is a SSOT path, never an absolute path under the user's home
|
|
187
|
+
assert not target.startswith("/"), \
|
|
188
|
+
f"{cls.__name__}: target_path must be repo-relative, got {target}"
|
|
189
|
+
assert "Agentik_SSOT/" in target or "Agentik_AI/" in target, (
|
|
190
|
+
f"{cls.__name__}: target_path must live under Agentik_SSOT/ or "
|
|
191
|
+
f"Agentik_AI/, got {target}"
|
|
192
|
+
)
|
|
193
|
+
print(" every educator returns a SSOT-relative target_path")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_router_role_naming():
|
|
197
|
+
"""Educators address the provider with role 'educator-<name>' (per-domain LLM)."""
|
|
198
|
+
# We verify by spying on the role through a tiny provider wrapper.
|
|
199
|
+
seen_roles: list[str] = []
|
|
200
|
+
|
|
201
|
+
class SpyProvider:
|
|
202
|
+
id = "spy"
|
|
203
|
+
|
|
204
|
+
def run(self, req):
|
|
205
|
+
seen_roles.append(req.role)
|
|
206
|
+
return MockProvider().run(req)
|
|
207
|
+
|
|
208
|
+
provider = SpyProvider()
|
|
209
|
+
for cls, intent, ctx, _expected in EDUCATOR_CASES:
|
|
210
|
+
cls().generate(intent, ctx, provider)
|
|
211
|
+
|
|
212
|
+
expected_roles = {f"educator-{cls().name}" for cls, *_ in EDUCATOR_CASES}
|
|
213
|
+
assert set(seen_roles) == expected_roles, (
|
|
214
|
+
f"router role naming mismatch.\nseen: {sorted(seen_roles)}\n"
|
|
215
|
+
f"expected: {sorted(expected_roles)}"
|
|
216
|
+
)
|
|
217
|
+
print(f" all 8 educators dispatched via 'educator-<name>' roles")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _run_all() -> bool:
|
|
221
|
+
tests = [v for k, v in sorted(globals().items())
|
|
222
|
+
if k.startswith("test_") and callable(v)]
|
|
223
|
+
passed = 0
|
|
224
|
+
for t in tests:
|
|
225
|
+
t()
|
|
226
|
+
print(f" PASS {t.__name__}")
|
|
227
|
+
passed += 1
|
|
228
|
+
print(f"\n{passed}/{len(tests)} educator tests passed")
|
|
229
|
+
return passed == len(tests)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(0 if _run_all() else 1)
|