@heretek-ai/epistemic-swarm 0.7.6 → 0.7.8
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/.claude-plugin/plugin.json +1 -1
- package/config/opencode-snippet.json +102 -149
- package/install.sh +81 -58
- package/package.json +1 -1
- package/plugins/opencode/index.js +72 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +7 -1
- package/runner/research_swarm.py +65 -3
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_factory.py +15 -7
- package/runner/tests/test_opencode_ux.py +37 -27
- package/runner/tests/test_opencode_v2_registrars.py +113 -16
- package/runner/tests/test_swarm_config.py +263 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/__init__.py +15 -2
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +103 -6
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/mcp_server.py
CHANGED
|
@@ -84,12 +84,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
84
84
|
"""Inspect or modify swarm parameters in .research/config.json."""
|
|
85
85
|
from skills.swarm_config.configure import (
|
|
86
86
|
display_config,
|
|
87
|
+
heal_config,
|
|
87
88
|
load_config,
|
|
88
89
|
save_config,
|
|
89
90
|
)
|
|
90
91
|
|
|
91
92
|
base_dir = str(_resolve_base_dir(args))
|
|
92
93
|
cfg = load_config(base_dir)
|
|
94
|
+
# Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
|
|
95
|
+
# stops silently spawning Claude on every run.
|
|
96
|
+
healed = heal_config(base_dir)
|
|
93
97
|
|
|
94
98
|
updates = {}
|
|
95
99
|
for key in (
|
|
@@ -99,6 +103,8 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
99
103
|
"depth",
|
|
100
104
|
"mode",
|
|
101
105
|
"divergence_threshold",
|
|
106
|
+
"backend",
|
|
107
|
+
"agents",
|
|
102
108
|
):
|
|
103
109
|
if key in args and args[key] is not None:
|
|
104
110
|
canon = {
|
|
@@ -119,7 +125,7 @@ def _handle_config(args: Dict[str, Any]) -> str:
|
|
|
119
125
|
}
|
|
120
126
|
)
|
|
121
127
|
|
|
122
|
-
return _tool_text({"status": "current", "config": cfg})
|
|
128
|
+
return _tool_text({"status": "current", "config": cfg, "migrated": healed})
|
|
123
129
|
|
|
124
130
|
|
|
125
131
|
def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
|
package/runner/research_swarm.py
CHANGED
|
@@ -66,6 +66,23 @@ def _backend_family(backend: List[str]) -> str:
|
|
|
66
66
|
return Path(backend[0]).name if backend else "claude"
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _agent_cwd(project_root: Path) -> str:
|
|
70
|
+
"""Working directory for spawned agent processes.
|
|
71
|
+
|
|
72
|
+
The canonical agent prompts name evidence paths RELATIVELY (`.research/...`)
|
|
73
|
+
and the runner reads dossiers from `<base_dir>/scratchpads/...`. Spawning in
|
|
74
|
+
the package root made those writes land inside node_modules (denied). Spawn
|
|
75
|
+
in the project root instead so `.research/` means the operator's evidence
|
|
76
|
+
tree. Override with IUMBTEMS_AGENT_CWD=package|project|<absolute path>.
|
|
77
|
+
"""
|
|
78
|
+
override = os.environ.get("IUMBTEMS_AGENT_CWD", "").strip()
|
|
79
|
+
if override == "package":
|
|
80
|
+
return str(PROJECT_ROOT)
|
|
81
|
+
if override and override != "project":
|
|
82
|
+
return override
|
|
83
|
+
return str(project_root)
|
|
84
|
+
|
|
85
|
+
|
|
69
86
|
def _build_opencode_cmd(
|
|
70
87
|
backend: List[str],
|
|
71
88
|
prompt: str,
|
|
@@ -190,6 +207,13 @@ class SwarmRunner:
|
|
|
190
207
|
domain_pack: Optional[str] = None,
|
|
191
208
|
):
|
|
192
209
|
self.base_dir = base_dir or Path(".research")
|
|
210
|
+
# Project root = the directory containing the evidence dir. Agents are
|
|
211
|
+
# spawned here so the relative `.research/...` paths named by the
|
|
212
|
+
# canonical prompts resolve to the RUNNER's evidence tree. Spawning in
|
|
213
|
+
# PROJECT_ROOT (the installed package) sent those writes into
|
|
214
|
+
# node_modules, where they were denied and no agent-authored dossier
|
|
215
|
+
# ever landed — runs survived only on the stdout-recovery fallback.
|
|
216
|
+
self.project_root = Path(os.path.realpath(str(self.base_dir))).parent
|
|
193
217
|
self.mock_mode = mock_mode
|
|
194
218
|
self.config = load_config(str(self.base_dir))
|
|
195
219
|
cfg = self.config
|
|
@@ -233,8 +257,46 @@ class SwarmRunner:
|
|
|
233
257
|
or _default_backend_cmd(self.config.get("backend"))
|
|
234
258
|
)
|
|
235
259
|
model = override.get("model") or env_model or role_cfg.get("model")
|
|
260
|
+
self._warn_backend_host_mismatch(role, backend)
|
|
236
261
|
return list(backend), model
|
|
237
262
|
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _warn_backend_host_mismatch(role: str, backend: List[str]) -> None:
|
|
265
|
+
"""Surface a config pin that silently defeats the host-native default.
|
|
266
|
+
|
|
267
|
+
Observed live: a pre-0.7.6 config pin of ["claude", "-p"] beat
|
|
268
|
+
IUMBTEMS_HOST=opencode, so an OpenCode host spawned Claude with no
|
|
269
|
+
signal. Warn loudly instead of failing silently.
|
|
270
|
+
"""
|
|
271
|
+
host = os.environ.get("IUMBTEMS_HOST", "").strip().lower()
|
|
272
|
+
if host not in ("claude", "opencode"):
|
|
273
|
+
return
|
|
274
|
+
family = _backend_family(backend)
|
|
275
|
+
if family == host:
|
|
276
|
+
return
|
|
277
|
+
sys.stderr.write(
|
|
278
|
+
f"[swarm] WARNING: role={role} resolved backend={family!r} from an "
|
|
279
|
+
f"explicit pin/override, but IUMBTEMS_HOST={host!r}. The explicit pin "
|
|
280
|
+
f"wins. Set backend: {host!r}, agents.{role}.backend: null, or "
|
|
281
|
+
f"IUMBTEMS_BACKEND_{role.upper()}={host} to follow the host.\n"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _scope_prompt(self, role: str, scope: Dict[str, Any], scope_id: str) -> str:
|
|
285
|
+
"""Prompt for a dialectic agent, naming the absolute scratchpad dir.
|
|
286
|
+
|
|
287
|
+
The canonical prompts describe evidence paths RELATIVELY, so a bare
|
|
288
|
+
scope id left the agent guessing which `.research` tree to write to.
|
|
289
|
+
Naming the absolute directory removes the ambiguity even when the
|
|
290
|
+
spawn cwd is overridden.
|
|
291
|
+
"""
|
|
292
|
+
return (
|
|
293
|
+
f"Run Agent {role} ({self.mode} mode) for scope: {json.dumps(scope)}. "
|
|
294
|
+
f"Engine: {self.engine}. Depth: {self.depth}. "
|
|
295
|
+
f"Save findings to the scratchpad directory "
|
|
296
|
+
f"{self.state_machine.get_scope_dir(scope_id)} (scope {scope_id}); "
|
|
297
|
+
f"write the dossier files there and nowhere else."
|
|
298
|
+
)
|
|
299
|
+
|
|
238
300
|
def _resolve_opencode_agent(self, role: str) -> Optional[str]:
|
|
239
301
|
"""Explicitly configured OpenCode agent for this role, else None.
|
|
240
302
|
|
|
@@ -310,7 +372,7 @@ class SwarmRunner:
|
|
|
310
372
|
capture_output=True,
|
|
311
373
|
text=True,
|
|
312
374
|
check=True,
|
|
313
|
-
cwd=
|
|
375
|
+
cwd=_agent_cwd(self.project_root),
|
|
314
376
|
# stdin MUST be DEVNULL: `opencode run` reads piped stdin to
|
|
315
377
|
# EOF before starting, and the MCP server's inherited stdin
|
|
316
378
|
# pipe is held open by the harness — every agent hung forever
|
|
@@ -619,7 +681,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
619
681
|
"negative_knowledge": [],
|
|
620
682
|
}
|
|
621
683
|
else:
|
|
622
|
-
prompt =
|
|
684
|
+
prompt = self._scope_prompt("Alpha", scope, scope_id)
|
|
623
685
|
if self.mode == "audit":
|
|
624
686
|
system_prompt = self.prompts_dir / "agent_code_auditor.md"
|
|
625
687
|
elif self.mode == "scout":
|
|
@@ -814,7 +876,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
814
876
|
],
|
|
815
877
|
}
|
|
816
878
|
else:
|
|
817
|
-
prompt =
|
|
879
|
+
prompt = self._scope_prompt("Beta", scope, scope_id)
|
|
818
880
|
if self.mode == "audit":
|
|
819
881
|
system_prompt = self.prompts_dir / "agent_code_auditor.md"
|
|
820
882
|
elif self.mode == "scout":
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -214,16 +214,24 @@ class TestFactoryHelper(unittest.TestCase):
|
|
|
214
214
|
def test_snippet_factory_roster(self):
|
|
215
215
|
with open(PROJECT_ROOT / "config" / "opencode-snippet.json") as f:
|
|
216
216
|
snippet = json.load(f)
|
|
217
|
+
# V2 config key is `agents` (plural) and rules are a `permissions` list.
|
|
217
218
|
for agent in ("manager", "programmer", "qa-a", "qa-b"):
|
|
218
|
-
self.assertIn(agent, snippet["
|
|
219
|
-
|
|
219
|
+
self.assertIn(agent, snippet["agents"])
|
|
220
|
+
|
|
221
|
+
def rule(agent, action, resource="*"):
|
|
222
|
+
return next(
|
|
223
|
+
r
|
|
224
|
+
for r in snippet["agents"][agent]["permissions"]
|
|
225
|
+
if r["action"] == action and r.get("resource", "*") == resource
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
manager = snippet["agents"]["manager"]
|
|
220
229
|
self.assertEqual(manager["mode"], "primary")
|
|
221
|
-
|
|
222
|
-
self.assertEqual(task["*"], "deny")
|
|
230
|
+
self.assertEqual(rule("manager", "subagent")["effect"], "deny")
|
|
223
231
|
for allowed in ("programmer", "qa-a", "qa-b", "brainstormer", "darkharvester"):
|
|
224
|
-
self.assertEqual(
|
|
225
|
-
self.assertEqual(
|
|
226
|
-
self.assertEqual(
|
|
232
|
+
self.assertEqual(rule("manager", "subagent", allowed)["effect"], "allow")
|
|
233
|
+
self.assertEqual(rule("qa-a", "edit")["effect"], "deny")
|
|
234
|
+
self.assertEqual(rule("qa-b", "edit")["effect"], "deny")
|
|
227
235
|
|
|
228
236
|
def test_factory_commands_carry_agent_subtask(self):
|
|
229
237
|
res = run_node(
|
|
@@ -30,6 +30,20 @@ EXPECTED_COMMANDS = [
|
|
|
30
30
|
"domainexpansion",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
EXPECTED_AGENTS = [
|
|
34
|
+
"code-auditor",
|
|
35
|
+
"oss-scout",
|
|
36
|
+
"alpha-thesis",
|
|
37
|
+
"beta-redteam",
|
|
38
|
+
"epistemic-auditor",
|
|
39
|
+
"brainstormer",
|
|
40
|
+
"darkharvester",
|
|
41
|
+
"manager",
|
|
42
|
+
"programmer",
|
|
43
|
+
"qa-a",
|
|
44
|
+
"qa-b",
|
|
45
|
+
]
|
|
46
|
+
|
|
33
47
|
|
|
34
48
|
def run_node(code):
|
|
35
49
|
return subprocess.run(
|
|
@@ -259,48 +273,44 @@ class TestInstallOpenCode(unittest.TestCase):
|
|
|
259
273
|
self.assertEqual(first.returncode, 0, f"install failed: {first.stderr}")
|
|
260
274
|
oc_path = home / ".config" / "opencode" / "opencode.json"
|
|
261
275
|
cfg = json.loads(oc_path.read_text(encoding="utf-8"))
|
|
262
|
-
#
|
|
263
|
-
|
|
276
|
+
# V1 `plugin` migrated to V2 `plugins` in object form; the trailing
|
|
277
|
+
# bare options object is folded in and user options are preserved.
|
|
278
|
+
self.assertNotIn("plugin", cfg)
|
|
279
|
+
self.assertFalse(
|
|
280
|
+
any(isinstance(p, list) for p in cfg["plugins"]),
|
|
281
|
+
f"V2 plugins must not use tuple form: {cfg['plugins']}",
|
|
282
|
+
)
|
|
283
|
+
ours = [
|
|
264
284
|
p
|
|
265
|
-
for p in cfg["
|
|
266
|
-
if isinstance(p,
|
|
285
|
+
for p in cfg["plugins"]
|
|
286
|
+
if isinstance(p, dict)
|
|
287
|
+
and p.get("package") == "@heretek-ai/epistemic-swarm"
|
|
267
288
|
]
|
|
268
|
-
self.assertEqual(len(
|
|
289
|
+
self.assertEqual(len(ours), 1)
|
|
269
290
|
self.assertEqual(
|
|
270
|
-
|
|
291
|
+
ours[0]["options"],
|
|
271
292
|
{
|
|
272
293
|
"search_engine": "duckduckgo",
|
|
273
294
|
"max_iterations": 10,
|
|
274
295
|
"mode": "research",
|
|
275
296
|
},
|
|
276
297
|
)
|
|
277
|
-
self.
|
|
278
|
-
# Agents merged from the snippet
|
|
279
|
-
for agent in
|
|
280
|
-
"
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"epistemic-auditor",
|
|
285
|
-
"brainstormer",
|
|
286
|
-
"darkharvester",
|
|
287
|
-
"manager",
|
|
288
|
-
"programmer",
|
|
289
|
-
"qa-a",
|
|
290
|
-
"qa-b",
|
|
291
|
-
]:
|
|
292
|
-
self.assertIn(agent, cfg.get("agent", {}))
|
|
293
|
-
self.assertIn("iumbtems", cfg.get("mcp", {}))
|
|
294
|
-
cmd = cfg["mcp"]["iumbtems"]["command"]
|
|
298
|
+
self.assertIn("@prevalentware/opencode-goal-plugin", cfg["plugins"])
|
|
299
|
+
# Agents merged from the snippet under the V2 plural key.
|
|
300
|
+
for agent in EXPECTED_AGENTS:
|
|
301
|
+
self.assertIn(agent, cfg.get("agents", {}))
|
|
302
|
+
# MCP server absolute under V2 mcp.servers (no legacy top-level key).
|
|
303
|
+
self.assertNotIn("iumbtems", cfg["mcp"])
|
|
304
|
+
cmd = cfg["mcp"]["servers"]["iumbtems"]["command"]
|
|
295
305
|
self.assertTrue(Path(cmd[-1]).is_absolute())
|
|
296
306
|
self.assertEqual(cmd[-1], str(PROJECT_ROOT / "runner" / "mcp_server.py"))
|
|
297
|
-
# Skills merged as absolute repo paths
|
|
298
|
-
skill_paths = cfg.get("skills"
|
|
307
|
+
# Skills merged as a V2 list of absolute repo paths.
|
|
308
|
+
skill_paths = cfg.get("skills")
|
|
309
|
+
self.assertIsInstance(skill_paths, list)
|
|
299
310
|
self.assertTrue(skill_paths)
|
|
300
311
|
for sp in skill_paths:
|
|
301
312
|
self.assertTrue(Path(sp).is_absolute(), f"relative skill path: {sp}")
|
|
302
313
|
self.assertIn(str(PROJECT_ROOT / "skills" / "grilling"), skill_paths)
|
|
303
|
-
self.assertFalse(any(not Path(sp).is_absolute() for sp in skill_paths))
|
|
304
314
|
# Second run changes nothing.
|
|
305
315
|
before = oc_path.read_bytes()
|
|
306
316
|
second = self._run_install(home)
|
|
@@ -7,6 +7,12 @@ ran as the generic `build` agent, and skills were undiscoverable — the agent
|
|
|
7
7
|
ran `find / -name factory.py` to learn its own mechanics. These tests pin the
|
|
8
8
|
plugin-side fix: profiles and skills are registered through the V2 domains, and
|
|
9
9
|
the command switches the session agent.
|
|
10
|
+
|
|
11
|
+
Regression (0.7.6, opencode v2.0.18): the mock below used to hand the plugin an
|
|
12
|
+
`{add}` agent editor. The real V2 `AgentEditor` has NO `add()` — only
|
|
13
|
+
`list/get/default/update/remove`, where `update()` upserts. The plugin's
|
|
14
|
+
`draft.add(...)` threw `draft.add is not a function` and the host disabled the
|
|
15
|
+
whole plugin. The mock now models the real editor so that defect cannot pass.
|
|
10
16
|
"""
|
|
11
17
|
|
|
12
18
|
import json
|
|
@@ -16,21 +22,71 @@ from pathlib import Path
|
|
|
16
22
|
|
|
17
23
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
# Faithful V2 AgentEditor: `update(id, fn)` upserts, seeding a new entry from
|
|
26
|
+
# Agent.Info.default(id) (mode primary, allow-list) + global rules, exactly as
|
|
27
|
+
# the opencode v2.0.18 agent registry does. No `add`.
|
|
28
|
+
AGENT_EDITOR_JS = """
|
|
29
|
+
function makeAgentEditor(reg) {
|
|
30
|
+
const store = new Map();
|
|
31
|
+
return {
|
|
32
|
+
store,
|
|
33
|
+
editor: {
|
|
34
|
+
list: () => [...store.values()],
|
|
35
|
+
get: (id) => store.get(id),
|
|
36
|
+
default: (id) => { reg.defaultAgent = id; },
|
|
37
|
+
update: (id, fn) => {
|
|
38
|
+
reg.agentUpdates.push(id);
|
|
39
|
+
let agent = store.get(id);
|
|
40
|
+
if (!agent) {
|
|
41
|
+
agent = {
|
|
42
|
+
id, name: id,
|
|
43
|
+
request: {settings: {}, headers: {}, body: {}},
|
|
44
|
+
mode: 'primary', hidden: false,
|
|
45
|
+
permissions: [{action: '*', resource: '*', effect: 'allow'}],
|
|
46
|
+
};
|
|
47
|
+
store.set(id, agent);
|
|
48
|
+
}
|
|
49
|
+
fn(agent);
|
|
50
|
+
agent.id = id;
|
|
51
|
+
},
|
|
52
|
+
remove: (id) => { store.delete(id); },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function seedAgent(box, id) {
|
|
57
|
+
box.store.set(id, {
|
|
58
|
+
id, name: id,
|
|
59
|
+
request: {settings: {}, headers: {}, body: {}},
|
|
60
|
+
mode: 'primary', hidden: false,
|
|
61
|
+
permissions: [{action: '*', resource: '*', effect: 'allow'}],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
V2_HOST = (
|
|
67
|
+
AGENT_EDITOR_JS
|
|
68
|
+
+ """
|
|
69
|
+
const reg = {skills: [], commands: [], tools: [], prompts: [], switches: [], agentUpdates: [], defaultAgent: undefined};
|
|
70
|
+
const agentBox = makeAgentEditor(reg);
|
|
21
71
|
const host = {
|
|
22
72
|
options: {},
|
|
23
73
|
location: {directory: '/home/john/Projects/STC'},
|
|
24
74
|
command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(x)=>{reg.commands.push(x);}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
25
75
|
tool: {transform: async(fn)=>{fn({add:(t)=>{reg.tools.push(t);}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
26
|
-
agent: {
|
|
76
|
+
agent: {
|
|
77
|
+
list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id, name: a.name}))}),
|
|
78
|
+
transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
|
|
79
|
+
reload: async()=>{}
|
|
80
|
+
},
|
|
27
81
|
skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s);}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
28
82
|
session: {
|
|
29
83
|
prompt: async(p)=>{reg.prompts.push(p); return {};},
|
|
30
84
|
switchAgent: async(a)=>{reg.switches.push(a); return {};}
|
|
31
85
|
}
|
|
32
86
|
};
|
|
87
|
+
const agents = () => [...agentBox.store.values()];
|
|
33
88
|
"""
|
|
89
|
+
)
|
|
34
90
|
|
|
35
91
|
|
|
36
92
|
def run_node(code):
|
|
@@ -54,16 +110,17 @@ class TestV2Registration(unittest.TestCase):
|
|
|
54
110
|
+ """
|
|
55
111
|
import plugin from "./plugins/opencode/index.js";
|
|
56
112
|
await plugin.setup(host);
|
|
57
|
-
const manager =
|
|
58
|
-
const qa =
|
|
113
|
+
const manager = agents().find(a => a.id === 'manager');
|
|
114
|
+
const qa = agents().find(a => a.id === 'qa-a');
|
|
59
115
|
const factory = reg.skills.find(s => s.id === 'factory');
|
|
60
116
|
console.log(JSON.stringify({
|
|
61
|
-
agentIds:
|
|
117
|
+
agentIds: agents().map(a => a.id).sort(),
|
|
62
118
|
managerMode: manager?.mode,
|
|
63
119
|
managerSystem: Boolean(manager?.system),
|
|
64
120
|
managerPermissions: manager?.permissions?.length || 0,
|
|
65
121
|
managerDeniesEdit: (manager?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
|
|
66
122
|
qaMode: qa?.mode,
|
|
123
|
+
qaDeniesEdit: (qa?.permissions || []).some(p => p.action === 'edit' && p.effect === 'deny'),
|
|
67
124
|
skillIds: reg.skills.map(s => s.id).sort(),
|
|
68
125
|
factoryHasContent: Boolean(factory?.content?.includes('Factory')),
|
|
69
126
|
factoryPath: factory?.path,
|
|
@@ -85,11 +142,46 @@ class TestV2Registration(unittest.TestCase):
|
|
|
85
142
|
self.assertTrue(d["managerSystem"])
|
|
86
143
|
self.assertTrue(d["managerDeniesEdit"], "manager must not edit code")
|
|
87
144
|
self.assertEqual(d["qaMode"], "subagent")
|
|
145
|
+
self.assertTrue(d["qaDeniesEdit"], "qa must not edit code")
|
|
88
146
|
self.assertIn("factory", d["skillIds"])
|
|
89
147
|
self.assertIn("darkharvest", d["skillIds"])
|
|
90
148
|
self.assertTrue(d["factoryHasContent"])
|
|
91
149
|
self.assertTrue(d["factoryPath"].endswith("skills/factory/SKILL.md"))
|
|
92
150
|
|
|
151
|
+
def test_agent_editor_never_calls_add(self):
|
|
152
|
+
"""Pin the exact 0.7.6 failure: AgentEditor has no `add`."""
|
|
153
|
+
res = run_node(
|
|
154
|
+
"""
|
|
155
|
+
const reg = {agentUpdates: []};
|
|
156
|
+
const editor = {
|
|
157
|
+
list: () => [],
|
|
158
|
+
get: () => undefined,
|
|
159
|
+
default: () => {},
|
|
160
|
+
update: (id, fn) => {
|
|
161
|
+
reg.agentUpdates.push(id);
|
|
162
|
+
fn({id, permissions: []});
|
|
163
|
+
},
|
|
164
|
+
remove: () => {},
|
|
165
|
+
// intentionally NO add
|
|
166
|
+
};
|
|
167
|
+
const host = {
|
|
168
|
+
options: {}, location: {directory: '/tmp'},
|
|
169
|
+
command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
170
|
+
tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
|
|
171
|
+
agent: {list: async()=>({data:[]}), transform: async(fn)=>{fn(editor); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
172
|
+
skill: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
173
|
+
session: {prompt: async()=>({})}
|
|
174
|
+
};
|
|
175
|
+
import plugin from "./plugins/opencode/index.js";
|
|
176
|
+
await plugin.setup(host);
|
|
177
|
+
console.log(JSON.stringify({agentUpdates: reg.agentUpdates}));
|
|
178
|
+
"""
|
|
179
|
+
)
|
|
180
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
181
|
+
d = last_json_object(res.stdout)
|
|
182
|
+
self.assertIn("manager", d["agentUpdates"])
|
|
183
|
+
self.assertIn("qa-b", d["agentUpdates"])
|
|
184
|
+
|
|
93
185
|
def test_command_switches_session_agent(self):
|
|
94
186
|
res = run_node(
|
|
95
187
|
V2_HOST
|
|
@@ -100,7 +192,7 @@ class TestV2Registration(unittest.TestCase):
|
|
|
100
192
|
await factory.execute({sessionID: 'ses_test', prompt: {text: 'Cockpit beta'}, delivery: 'steer'});
|
|
101
193
|
console.log(JSON.stringify({
|
|
102
194
|
switches: reg.switches,
|
|
103
|
-
|
|
195
|
+
promptAgentKey: Object.prototype.hasOwnProperty.call(reg.prompts[0] || {}, 'agent'),
|
|
104
196
|
promptHasArena: reg.prompts[0]?.text?.includes('Cockpit beta'),
|
|
105
197
|
}));
|
|
106
198
|
"""
|
|
@@ -108,39 +200,44 @@ class TestV2Registration(unittest.TestCase):
|
|
|
108
200
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
109
201
|
d = last_json_object(res.stdout)
|
|
110
202
|
self.assertTrue(d["switches"], "switchAgent was never called")
|
|
111
|
-
self.assertEqual(d["
|
|
203
|
+
self.assertEqual(d["switches"][0]["agent"], "manager")
|
|
204
|
+
# V2 SessionPromptInput has no `agent` field; pinning is switchAgent's job.
|
|
205
|
+
self.assertFalse(d["promptAgentKey"])
|
|
112
206
|
self.assertTrue(d["promptHasArena"])
|
|
113
207
|
|
|
114
208
|
def test_preexisting_agent_and_skill_preserved(self):
|
|
115
209
|
res = run_node(
|
|
116
|
-
|
|
117
|
-
|
|
210
|
+
AGENT_EDITOR_JS
|
|
211
|
+
+ """
|
|
212
|
+
const reg = {agentUpdates: [], skills: []};
|
|
213
|
+
const agentBox = makeAgentEditor(reg);
|
|
214
|
+
seedAgent(agentBox, 'manager'); // user-defined manager wins
|
|
118
215
|
const host = {
|
|
119
216
|
options: {}, location: {directory: '/tmp'},
|
|
120
217
|
command: {list: async()=>({data:[]}), transform: async(fn)=>{fn({add:()=>{}}); return {dispose:()=>{}};}, reload: async()=>{}},
|
|
121
218
|
tool: {transform: async()=>({dispose:()=>{}}), reload: async()=>{}},
|
|
122
219
|
agent: {
|
|
123
|
-
list: async()=>({data:[{id:
|
|
124
|
-
transform: async(fn)=>{fn(
|
|
220
|
+
list: async()=>({data: [...agentBox.store.values()].map((a)=>({id: a.id}))}),
|
|
221
|
+
transform: async(fn)=>{fn(agentBox.editor); return {dispose:()=>{}};},
|
|
125
222
|
reload: async()=>{}
|
|
126
223
|
},
|
|
127
224
|
skill: {
|
|
128
225
|
list: async()=>({data:[{id:'factory'}]}),
|
|
129
|
-
transform: async(fn)=>{fn({add:(s)=>{skills.push(s.id);}}); return {dispose:()=>{}};},
|
|
226
|
+
transform: async(fn)=>{fn({add:(s)=>{reg.skills.push(s.id);}}); return {dispose:()=>{}};},
|
|
130
227
|
reload: async()=>{}
|
|
131
228
|
},
|
|
132
229
|
session: {prompt: async()=>({})}
|
|
133
230
|
};
|
|
134
231
|
import plugin from "./plugins/opencode/index.js";
|
|
135
232
|
await plugin.setup(host);
|
|
136
|
-
console.log(JSON.stringify({
|
|
233
|
+
console.log(JSON.stringify({agentUpdates: reg.agentUpdates, skills: reg.skills}));
|
|
137
234
|
"""
|
|
138
235
|
)
|
|
139
236
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
140
237
|
d = last_json_object(res.stdout)
|
|
141
|
-
self.assertNotIn("manager", d["
|
|
238
|
+
self.assertNotIn("manager", d["agentUpdates"], "user-defined manager must win")
|
|
142
239
|
self.assertNotIn("factory", d["skills"], "user-defined factory skill must win")
|
|
143
|
-
self.assertIn("qa-a", d["
|
|
240
|
+
self.assertIn("qa-a", d["agentUpdates"])
|
|
144
241
|
|
|
145
242
|
def test_graceful_without_agent_skill_domains(self):
|
|
146
243
|
res = run_node(
|