@heretek-ai/epistemic-swarm 0.7.7 → 0.7.9
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/package.json +1 -1
- 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 +72 -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_swarm_config.py +266 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"description": "IUMBTEMS: I Use My Brain To Express My Self — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi, OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
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
|
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
|
|
|
@@ -305,12 +367,19 @@ class SwarmRunner:
|
|
|
305
367
|
try:
|
|
306
368
|
if cmd:
|
|
307
369
|
_validate_backend([cmd[0]])
|
|
370
|
+
agent_cwd = _agent_cwd(self.project_root)
|
|
308
371
|
res = subprocess.run(
|
|
309
372
|
cmd,
|
|
310
373
|
capture_output=True,
|
|
311
374
|
text=True,
|
|
312
375
|
check=True,
|
|
313
|
-
cwd=
|
|
376
|
+
cwd=agent_cwd,
|
|
377
|
+
# `opencode run` resolves its project root from $PWD, not the OS
|
|
378
|
+
# cwd — and subprocess.run(cwd=...) does NOT update PWD. Without
|
|
379
|
+
# this the worker operated on the launcher's PWD (observed live:
|
|
380
|
+
# runner evidence dir 5da7ec/sunny-otter, worker cwd
|
|
381
|
+
# /home/john/Projects/STC). Keep PWD consistent with cwd.
|
|
382
|
+
env={**os.environ, "PWD": agent_cwd},
|
|
314
383
|
# stdin MUST be DEVNULL: `opencode run` reads piped stdin to
|
|
315
384
|
# EOF before starting, and the MCP server's inherited stdin
|
|
316
385
|
# pipe is held open by the harness — every agent hung forever
|
|
@@ -619,7 +688,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
619
688
|
"negative_knowledge": [],
|
|
620
689
|
}
|
|
621
690
|
else:
|
|
622
|
-
prompt =
|
|
691
|
+
prompt = self._scope_prompt("Alpha", scope, scope_id)
|
|
623
692
|
if self.mode == "audit":
|
|
624
693
|
system_prompt = self.prompts_dir / "agent_code_auditor.md"
|
|
625
694
|
elif self.mode == "scout":
|
|
@@ -814,7 +883,7 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
814
883
|
],
|
|
815
884
|
}
|
|
816
885
|
else:
|
|
817
|
-
prompt =
|
|
886
|
+
prompt = self._scope_prompt("Beta", scope, scope_id)
|
|
818
887
|
if self.mode == "audit":
|
|
819
888
|
system_prompt = self.prompts_dir / "agent_code_auditor.md"
|
|
820
889
|
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
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Config-merge and agent-spawn regressions.
|
|
3
|
+
|
|
4
|
+
Covers two live failures observed on an OpenCode host running the installed
|
|
5
|
+
package against an operator project:
|
|
6
|
+
|
|
7
|
+
1. A pre-0.7.6 config persisted `agents.<role>.backend = ["claude", "-p"]`.
|
|
8
|
+
The shallow merge in `load_config` preserved it and it out-ranked the
|
|
9
|
+
host-native default, so an OpenCode host silently spawned Claude.
|
|
10
|
+
2. Agents were spawned with `cwd=PROJECT_ROOT` (the installed package) while
|
|
11
|
+
evidence lives in the project's `.research/`, so prompt-mandated relative
|
|
12
|
+
writes landed inside node_modules and were denied.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from contextlib import redirect_stderr
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from unittest import mock
|
|
25
|
+
|
|
26
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
27
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
29
|
+
|
|
30
|
+
from runner.research_swarm import ( # noqa: E402
|
|
31
|
+
PROJECT_ROOT as PACKAGE_ROOT,
|
|
32
|
+
SwarmRunner,
|
|
33
|
+
_agent_cwd,
|
|
34
|
+
)
|
|
35
|
+
from skills.swarm_config.configure import ( # noqa: E402
|
|
36
|
+
DEFAULT_CONFIG,
|
|
37
|
+
heal_config,
|
|
38
|
+
load_config,
|
|
39
|
+
migrate_legacy_agent_backends,
|
|
40
|
+
save_config,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_config(base_dir: Path, payload: dict) -> Path:
|
|
45
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
cfg_file = base_dir / "config.json"
|
|
47
|
+
cfg_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
48
|
+
return cfg_file
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestLegacyAgentPinMigration(unittest.TestCase):
|
|
52
|
+
def test_legacy_pin_is_dropped_when_backend_is_auto(self):
|
|
53
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
54
|
+
base = Path(tmp) / ".research"
|
|
55
|
+
_write_config(
|
|
56
|
+
base,
|
|
57
|
+
{
|
|
58
|
+
"backend": "auto",
|
|
59
|
+
"agents": {
|
|
60
|
+
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
61
|
+
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
cfg = load_config(str(base))
|
|
66
|
+
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
67
|
+
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
68
|
+
|
|
69
|
+
def test_legacy_pin_no_longer_forces_claude_on_opencode_host(self):
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
71
|
+
base = Path(tmp) / ".research"
|
|
72
|
+
_write_config(
|
|
73
|
+
base,
|
|
74
|
+
{
|
|
75
|
+
"backend": "auto",
|
|
76
|
+
"agents": {
|
|
77
|
+
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
78
|
+
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
83
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
|
|
84
|
+
cmd = runner.build_agent_cmd("objective", role="alpha")
|
|
85
|
+
self.assertEqual(cmd[:2], ["opencode", "run"])
|
|
86
|
+
|
|
87
|
+
def test_explicit_claude_choice_is_preserved(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
base = Path(tmp) / ".research"
|
|
90
|
+
_write_config(
|
|
91
|
+
base,
|
|
92
|
+
{
|
|
93
|
+
"backend": "claude",
|
|
94
|
+
"agents": {
|
|
95
|
+
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
96
|
+
"beta": {"backend": ["claude", "-p"], "model": None},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
101
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
102
|
+
with redirect_stderr(io.StringIO()):
|
|
103
|
+
cmd = runner.build_agent_cmd("objective", role="alpha")
|
|
104
|
+
self.assertEqual(cmd[:2], ["claude", "-p"])
|
|
105
|
+
|
|
106
|
+
def test_backend_opencode_top_level_drops_legacy_pin(self):
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
108
|
+
base = Path(tmp) / ".research"
|
|
109
|
+
_write_config(
|
|
110
|
+
base,
|
|
111
|
+
{
|
|
112
|
+
"backend": "opencode",
|
|
113
|
+
"agents": {
|
|
114
|
+
"alpha": {"backend": ["claude", "-p"], "model": None},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
cfg = load_config(str(base))
|
|
119
|
+
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
120
|
+
|
|
121
|
+
def test_migration_helper_reports_change(self):
|
|
122
|
+
cfg = {
|
|
123
|
+
"backend": "auto",
|
|
124
|
+
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
125
|
+
}
|
|
126
|
+
self.assertTrue(migrate_legacy_agent_backends(cfg))
|
|
127
|
+
self.assertFalse(migrate_legacy_agent_backends(cfg))
|
|
128
|
+
|
|
129
|
+
def test_heal_config_persists_and_is_idempotent(self):
|
|
130
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
131
|
+
base = Path(tmp) / ".research"
|
|
132
|
+
cfg_file = _write_config(
|
|
133
|
+
base,
|
|
134
|
+
{
|
|
135
|
+
"backend": "auto",
|
|
136
|
+
"agents": {"alpha": {"backend": ["claude", "-p"], "model": None}},
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
self.assertTrue(heal_config(str(base)))
|
|
140
|
+
on_disk = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
141
|
+
self.assertIsNone(on_disk["agents"]["alpha"]["backend"])
|
|
142
|
+
# Second pass has nothing left to migrate.
|
|
143
|
+
self.assertFalse(heal_config(str(base)))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestConfigMergeSemantics(unittest.TestCase):
|
|
147
|
+
def test_partial_agents_block_does_not_clobber_defaults(self):
|
|
148
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
149
|
+
base = Path(tmp) / ".research"
|
|
150
|
+
_write_config(base, {"agents": {"alpha": {"model": "some-model"}}})
|
|
151
|
+
cfg = load_config(str(base))
|
|
152
|
+
self.assertEqual(cfg["agents"]["alpha"]["model"], "some-model")
|
|
153
|
+
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
154
|
+
# Beta was absent from the file and keeps its defaults.
|
|
155
|
+
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
156
|
+
|
|
157
|
+
def test_loaded_config_does_not_alias_default_config(self):
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
159
|
+
base = Path(tmp) / ".research"
|
|
160
|
+
cfg = load_config(str(base))
|
|
161
|
+
cfg["agents"]["alpha"]["backend"] = "MUTATED"
|
|
162
|
+
cfg["license_whitelist"].append("MUTATED")
|
|
163
|
+
self.assertIsNone(DEFAULT_CONFIG["agents"]["alpha"]["backend"])
|
|
164
|
+
self.assertNotIn("MUTATED", DEFAULT_CONFIG["license_whitelist"])
|
|
165
|
+
|
|
166
|
+
def test_malformed_agents_block_is_tolerated(self):
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
base = Path(tmp) / ".research"
|
|
169
|
+
_write_config(base, {"agents": None})
|
|
170
|
+
cfg = load_config(str(base))
|
|
171
|
+
self.assertIsNone(cfg["agents"]["alpha"]["backend"])
|
|
172
|
+
|
|
173
|
+
_write_config(base, {"agents": {"alpha": None}})
|
|
174
|
+
cfg = load_config(str(base))
|
|
175
|
+
self.assertIsNone(cfg["agents"]["alpha"])
|
|
176
|
+
self.assertIsNone(cfg["agents"]["beta"]["backend"])
|
|
177
|
+
|
|
178
|
+
def test_non_object_config_falls_back_to_defaults(self):
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
180
|
+
base = Path(tmp) / ".research"
|
|
181
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
(base / "config.json").write_text("[1, 2, 3]", encoding="utf-8")
|
|
183
|
+
with redirect_stderr(io.StringIO()):
|
|
184
|
+
cfg = load_config(str(base))
|
|
185
|
+
self.assertEqual(cfg["search_engine"], "duckduckgo")
|
|
186
|
+
|
|
187
|
+
def test_save_load_roundtrip_is_idempotent(self):
|
|
188
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
189
|
+
base = Path(tmp) / ".research"
|
|
190
|
+
save_config(load_config(str(base)), str(base))
|
|
191
|
+
first = load_config(str(base))
|
|
192
|
+
save_config(first, str(base))
|
|
193
|
+
self.assertEqual(first, load_config(str(base)))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestAgentSpawnWorkingDirectory(unittest.TestCase):
|
|
197
|
+
def test_project_root_is_evidence_dir_parent(self):
|
|
198
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
199
|
+
base = Path(tmp) / ".research"
|
|
200
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
201
|
+
self.assertEqual(
|
|
202
|
+
runner.project_root, Path(os.path.realpath(str(base))).parent
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def test_agent_cwd_defaults_to_project_root(self):
|
|
206
|
+
project = Path("/tmp/some-project")
|
|
207
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
208
|
+
self.assertEqual(_agent_cwd(project), str(project))
|
|
209
|
+
|
|
210
|
+
def test_agent_cwd_package_override(self):
|
|
211
|
+
with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "package"}):
|
|
212
|
+
self.assertEqual(_agent_cwd(Path("/tmp/x")), str(PACKAGE_ROOT))
|
|
213
|
+
|
|
214
|
+
def test_agent_cwd_absolute_override(self):
|
|
215
|
+
with mock.patch.dict(os.environ, {"IUMBTEMS_AGENT_CWD": "/custom/dir"}):
|
|
216
|
+
self.assertEqual(_agent_cwd(Path("/tmp/x")), "/custom/dir")
|
|
217
|
+
|
|
218
|
+
def test_scope_prompt_names_absolute_scratchpad(self):
|
|
219
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
220
|
+
base = Path(tmp) / ".research"
|
|
221
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
|
|
222
|
+
prompt = runner._scope_prompt("Alpha", {"scope_id": "s1"}, "s1")
|
|
223
|
+
expected = str(runner.state_machine.get_scope_dir("s1"))
|
|
224
|
+
self.assertIn(expected, prompt)
|
|
225
|
+
self.assertTrue(Path(expected).is_absolute())
|
|
226
|
+
|
|
227
|
+
def test_spawn_uses_project_root_not_package_root(self):
|
|
228
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
229
|
+
base = Path(tmp) / ".research"
|
|
230
|
+
runner = SwarmRunner(base_dir=base, mock_mode=False, mode="research")
|
|
231
|
+
fake = mock.Mock()
|
|
232
|
+
fake.stdout = "ok"
|
|
233
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
234
|
+
with mock.patch("subprocess.run", return_value=fake) as run_mock:
|
|
235
|
+
runner.run_claude_process("objective text")
|
|
236
|
+
kwargs = run_mock.call_args.kwargs
|
|
237
|
+
self.assertEqual(kwargs["cwd"], str(runner.project_root))
|
|
238
|
+
self.assertNotEqual(kwargs["cwd"], str(PACKAGE_ROOT))
|
|
239
|
+
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
|
|
240
|
+
# `opencode run` follows $PWD, which subprocess.run(cwd=...) does not
|
|
241
|
+
# update — keep them identical.
|
|
242
|
+
self.assertEqual(kwargs["env"]["PWD"], kwargs["cwd"])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TestBackendHostMismatchWarning(unittest.TestCase):
|
|
246
|
+
def test_warns_when_pin_contradicts_host(self):
|
|
247
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
248
|
+
base = Path(tmp) / ".research"
|
|
249
|
+
_write_config(
|
|
250
|
+
base,
|
|
251
|
+
{
|
|
252
|
+
"backend": "claude",
|
|
253
|
+
"agents": {"alpha": {"backend": ["claude", "-p"]}},
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
with mock.patch.dict(os.environ, {"IUMBTEMS_HOST": "opencode"}):
|
|
257
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
258
|
+
buf = io.StringIO()
|
|
259
|
+
with redirect_stderr(buf):
|
|
260
|
+
runner.build_agent_cmd("objective", role="alpha")
|
|
261
|
+
self.assertIn("IUMBTEMS_HOST", buf.getvalue())
|
|
262
|
+
self.assertIn("alpha", buf.getvalue())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
unittest.main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
"""Epistemic Swarm configuration package."""
|
|
2
|
-
from .configure import load_config, save_config, DEFAULT_CONFIG
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
from .configure import (
|
|
4
|
+
DEFAULT_CONFIG,
|
|
5
|
+
heal_config,
|
|
6
|
+
load_config,
|
|
7
|
+
migrate_legacy_agent_backends,
|
|
8
|
+
save_config,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"load_config",
|
|
13
|
+
"save_config",
|
|
14
|
+
"heal_config",
|
|
15
|
+
"migrate_legacy_agent_backends",
|
|
16
|
+
"DEFAULT_CONFIG",
|
|
17
|
+
]
|
|
Binary file
|
|
Binary file
|
|
@@ -4,11 +4,12 @@ Interactive Configuration Manager for IUMBTEMS Epistemic Swarm.
|
|
|
4
4
|
Reads, modifies, and persists research parameters in .research/config.json.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import copy
|
|
7
8
|
import sys
|
|
8
9
|
import os
|
|
9
10
|
import json
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import
|
|
12
|
+
from typing import Any, Dict
|
|
12
13
|
|
|
13
14
|
DEFAULT_RESEARCH_DIR = ".research"
|
|
14
15
|
|
|
@@ -42,25 +43,117 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
# Historical pre-0.7.6 default that was persisted verbatim into user configs.
|
|
47
|
+
# A per-role pin equal to this value, while the top-level `backend` is not an
|
|
48
|
+
# explicit "claude", is a stale default rather than a deliberate choice: it must
|
|
49
|
+
# not override the host-native backend (opencode on OpenCode). Explicit intent
|
|
50
|
+
# lives in `backend: "claude"` or `IUMBTEMS_BACKEND_<ROLE>`.
|
|
51
|
+
LEGACY_AGENT_BACKEND_PINS = (["claude", "-p"],)
|
|
52
|
+
|
|
53
|
+
AGENT_ROLES = ("alpha", "beta")
|
|
54
|
+
|
|
55
|
+
|
|
45
56
|
def get_config_path(base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
|
|
46
57
|
return Path(base_dir) / "config.json"
|
|
47
58
|
|
|
48
59
|
|
|
60
|
+
def _merge_agents(default_agents: Any, file_agents: Any) -> Dict[str, Any]:
|
|
61
|
+
"""Deep-merge the per-role `agents` block, one role/key at a time.
|
|
62
|
+
|
|
63
|
+
A shallow `merged.update(data)` let a single on-disk role key clobber the
|
|
64
|
+
whole default block, which silently re-pinned every agent to whatever the
|
|
65
|
+
user's file happened to contain (the host-native default was never reached).
|
|
66
|
+
"""
|
|
67
|
+
merged = copy.deepcopy(default_agents) if isinstance(default_agents, dict) else {}
|
|
68
|
+
if not isinstance(file_agents, dict):
|
|
69
|
+
return merged
|
|
70
|
+
for role, role_cfg in file_agents.items():
|
|
71
|
+
if not isinstance(role_cfg, dict):
|
|
72
|
+
# Preserve unexpected shapes verbatim rather than crashing; the
|
|
73
|
+
# backend resolver tolerates non-dict role configs.
|
|
74
|
+
merged[role] = copy.deepcopy(role_cfg)
|
|
75
|
+
continue
|
|
76
|
+
base = merged.get(role)
|
|
77
|
+
if isinstance(base, dict):
|
|
78
|
+
base.update(copy.deepcopy(role_cfg))
|
|
79
|
+
else:
|
|
80
|
+
merged[role] = copy.deepcopy(role_cfg)
|
|
81
|
+
return merged
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def migrate_legacy_agent_backends(cfg: Dict[str, Any]) -> bool:
|
|
85
|
+
"""Drop stale pre-0.7.6 `["claude", "-p"]` pins in place; True if changed.
|
|
86
|
+
|
|
87
|
+
Only fires when the top-level backend is not an explicit "claude", so a user
|
|
88
|
+
who deliberately selected Claude is never silently switched to something
|
|
89
|
+
else. To keep Claude on an OpenCode host, set `backend: "claude"`.
|
|
90
|
+
"""
|
|
91
|
+
if str(cfg.get("backend") or "auto").strip().lower() == "claude":
|
|
92
|
+
return False
|
|
93
|
+
agents = cfg.get("agents")
|
|
94
|
+
if not isinstance(agents, dict):
|
|
95
|
+
return False
|
|
96
|
+
changed = False
|
|
97
|
+
for role in AGENT_ROLES:
|
|
98
|
+
role_cfg = agents.get(role)
|
|
99
|
+
if (
|
|
100
|
+
isinstance(role_cfg, dict)
|
|
101
|
+
and role_cfg.get("backend") in LEGACY_AGENT_BACKEND_PINS
|
|
102
|
+
):
|
|
103
|
+
role_cfg["backend"] = None
|
|
104
|
+
changed = True
|
|
105
|
+
return changed
|
|
106
|
+
|
|
107
|
+
|
|
49
108
|
def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
|
|
109
|
+
"""Load config merged over defaults. Pure — never writes to disk.
|
|
110
|
+
|
|
111
|
+
Applies the legacy-pin migration in memory so a stale config stops forcing
|
|
112
|
+
Claude immediately; `heal_config` persists that migration on next touch.
|
|
113
|
+
"""
|
|
114
|
+
base = copy.deepcopy(DEFAULT_CONFIG)
|
|
50
115
|
cfg_file = get_config_path(base_dir)
|
|
51
116
|
if not cfg_file.exists():
|
|
52
|
-
return
|
|
117
|
+
return base
|
|
53
118
|
try:
|
|
54
119
|
with open(cfg_file, "r", encoding="utf-8") as f:
|
|
55
120
|
data = json.load(f)
|
|
56
|
-
merged = dict(DEFAULT_CONFIG)
|
|
57
|
-
merged.update(data)
|
|
58
|
-
return merged
|
|
59
121
|
except Exception as e:
|
|
60
122
|
sys.stderr.write(
|
|
61
123
|
f"[swarm-config] Warning: Failed to parse {cfg_file}: {e}. Using defaults.\n"
|
|
62
124
|
)
|
|
63
|
-
return
|
|
125
|
+
return base
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
sys.stderr.write(
|
|
128
|
+
f"[swarm-config] Warning: {cfg_file} is not a JSON object. Using defaults.\n"
|
|
129
|
+
)
|
|
130
|
+
return base
|
|
131
|
+
merged: Dict[str, Any] = {**base, **data}
|
|
132
|
+
merged["agents"] = _merge_agents(base.get("agents", {}), data.get("agents"))
|
|
133
|
+
migrate_legacy_agent_backends(merged)
|
|
134
|
+
return merged
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def heal_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
|
|
138
|
+
"""Persist a legacy agent-backend migration to disk; True if rewritten.
|
|
139
|
+
|
|
140
|
+
`load_config` stays side-effect free; the CLI and MCP surfaces call this so
|
|
141
|
+
an affected install self-heals instead of re-pinning Claude on every save.
|
|
142
|
+
"""
|
|
143
|
+
cfg_file = get_config_path(base_dir)
|
|
144
|
+
if not cfg_file.exists():
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
with open(cfg_file, "r", encoding="utf-8") as f:
|
|
148
|
+
raw = json.load(f)
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
if not isinstance(raw, dict):
|
|
152
|
+
return False
|
|
153
|
+
if not migrate_legacy_agent_backends(raw):
|
|
154
|
+
return False
|
|
155
|
+
save_config(load_config(base_dir), base_dir)
|
|
156
|
+
return True
|
|
64
157
|
|
|
65
158
|
|
|
66
159
|
def save_config(cfg: Dict[str, Any], base_dir: str = DEFAULT_RESEARCH_DIR) -> Path:
|
|
@@ -218,6 +311,10 @@ def _parse_cli_updates(args: list, cfg: Dict[str, Any]) -> bool:
|
|
|
218
311
|
|
|
219
312
|
def main():
|
|
220
313
|
cfg = load_config()
|
|
314
|
+
if heal_config():
|
|
315
|
+
print(
|
|
316
|
+
"ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
|
|
317
|
+
)
|
|
221
318
|
args = sys.argv[1:]
|
|
222
319
|
|
|
223
320
|
if not args or "--show" in args:
|