Skip to content

Commit c5cf8fa

Browse files
mabry1985claude
andauthored
fix(devkit): testkit host-stubs cover SubagentConfig + Knobs so register() runs host-free (protoLabsAI#1773)
The plugin-devkit testkit's default host-stubs omitted `graph.subagents.config` entirely (ModuleNotFoundError) and handed back a raise-when-called `Knobs` / `make_knob_tools` from `graph.sdk`. So a scaffolded plugin whose `register()` registers a subagent (`registry.register_subagent(SubagentConfig(...))`) or wires runtime knobs failed its OWN host-free smoke test before the author wrote a line. These seams are CONSTRUCTED at register() time, not merely imported, so they need permissive RECORD-ONLY stand-ins (not the raise-when-called placeholder): - Stub `graph.subagents` + `graph.subagents.config` exposing `SubagentConfig`, a record that stores its kwargs as attributes — `from graph.subagents.config import SubagentConfig` imports and `reg.subagents[0].name` is assertable. - `graph.sdk` now exposes a chainable no-op `Knobs` (`.define`/`.preset` return self, reads mirror the surface) and `make_knob_tools(...)` returning record-only stub tools named like the real `<prefix>_knobs`/`_tune`/`_preset` — a plugin can `register_tools(make_knob_tools(...))` host-free and the contribution stays assertable. The remaining `graph.sdk` seams (complete/run_subagent/...) stay raise-unpatched so a model-touching test still fails loudly if unpatched. `graph/plugins/testkit.py` is the single source of truth — the scaffolder vendors it verbatim into each plugin's `tests/_plugin_testkit.py`, so newly-scaffolded plugins inherit the fix automatically. Closes protoLabsAI#1764 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7237513 commit c5cf8fa

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

graph/plugins/testkit.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,83 @@ def __getattr__(self, item: str):
109109
return _raise_unpatched(f"{self.__name__}.{item}")
110110

111111

112+
# Some host seams a plugin doesn't just IMPORT but CALLS at register() time — it constructs a
113+
# ``SubagentConfig`` and builds knob tools while wiring the registry. Those can't be the
114+
# raise-when-called placeholder (that turns a scaffolded plugin's own smoke test red before it
115+
# writes a line — #1764); they need permissive, RECORD-ONLY stand-ins that run host-free while
116+
# keeping the contribution assertable.
117+
118+
119+
class _StubSubagentConfig:
120+
"""Stand-in for ``graph.subagents.config.SubagentConfig`` — stores every kwarg as an
121+
attribute, so ``registry.register_subagent(SubagentConfig(name=..., ...))`` runs with no
122+
host and the captured config stays assertable (``reg.subagents[0].name``)."""
123+
124+
def __init__(self, **kwargs):
125+
self.__dict__.update(kwargs)
126+
127+
128+
class _StubKnobs:
129+
"""Chainable no-op stand-in for ``graph.knobs.Knobs`` (re-exported from ``graph.sdk``):
130+
``define``/``preset`` record the declaration and return ``self`` so a plugin's fluent knob
131+
setup runs host-free; the reads mirror the real surface so an engine that reads a default
132+
back at register() time still works (and the record stays assertable)."""
133+
134+
def __init__(self, *_a, **_k):
135+
self.defined: dict = {}
136+
self.defined_presets: dict = {}
137+
138+
def define(self, name=None, default=None, **_k) -> "_StubKnobs":
139+
if name is not None:
140+
self.defined[name] = default
141+
return self
142+
143+
def preset(self, name=None, overrides=None, **_k) -> "_StubKnobs":
144+
if name is not None:
145+
self.defined_presets[name] = dict(overrides or {})
146+
return self
147+
148+
def get(self, name):
149+
return self.defined.get(name)
150+
151+
def values(self) -> dict:
152+
return dict(self.defined)
153+
154+
def presets(self) -> dict:
155+
return dict(self.defined_presets)
156+
157+
158+
def _make_knob_tools(
159+
knobs=None, *, prefix: str = "knobs", show: bool = True, tune: bool = True, presets: bool = True, **_k
160+
) -> list:
161+
"""Stand-in for ``graph.knobs.make_knob_tools`` — returns one harmless, record-only stub
162+
tool per enabled control, named like the real ``<prefix>_knobs`` / ``_tune`` / ``_preset``,
163+
so a plugin can ``registry.register_tools(make_knob_tools(...))`` with no host and the tool
164+
contribution stays assertable (instead of the old raise-when-called placeholder)."""
165+
made: list = []
166+
for enabled, suffix in ((show, "knobs"), (tune, "tune"), (presets, "preset")):
167+
if enabled:
168+
made.append(types.SimpleNamespace(name=f"{prefix}_{suffix}"))
169+
return made
170+
171+
112172
# Default host surface, derived from what real plugins import (spacetraders, project_board,
113173
# notes, …). `extra` lets a plugin add its own; anything already importable is left alone.
114174
def _default_stubs() -> dict:
115175
return {
116176
"graph": {},
117-
"graph.sdk": {}, # run_subagent / subagent_types / config / complete
177+
# Knobs/make_knob_tools are CALLED at register() time, so they're real stand-ins (not
178+
# raise-when-called); the rest (run_subagent / subagent_types / config / complete) stay
179+
# raise-unpatched placeholders — patch them in a test that exercises the model seam.
180+
"graph.sdk": {"Knobs": _StubKnobs, "make_knob_tools": _make_knob_tools},
118181
"graph.config": {"LangGraphConfig": type("LangGraphConfig", (), {})},
119182
"graph.config_io": {"secrets_yaml_path": lambda: Path("config/secrets.yaml")},
120183
"graph.goals": {},
121184
"graph.goals.types": {
122185
"VerifyResult": type("VerifyResult", (), {"__init__": lambda self, **kw: self.__dict__.update(kw)})
123186
},
187+
"graph.subagents": {},
188+
"graph.subagents.config": {"SubagentConfig": _StubSubagentConfig},
124189
"knowledge": {},
125190
"knowledge.store": {"KnowledgeStore": type("KnowledgeStore", (), {})},
126191
}

tests/test_plugin_testkit.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,67 @@ def test_install_host_stubs_is_idempotent():
208208
first = testkit.install_host_stubs(extra={"phantom_host": {}, "phantom_host.sdk": {}})
209209
second = testkit.install_host_stubs(extra={"phantom_host": {}, "phantom_host.sdk": {}})
210210
assert "phantom_host.sdk" in first and second == [] # nothing re-installed the 2nd time
211+
212+
213+
# ── subagent-config + knobs stubs (#1764) ────────────────────────────────────────────
214+
# A plugin's register() doesn't just IMPORT host seams — it CONSTRUCTS a SubagentConfig and
215+
# builds knob tools inline. Before #1764 `graph.subagents.config` was unstubbed
216+
# (ModuleNotFoundError) and `graph.sdk` handed back a raise-when-called Knobs, so a scaffolded
217+
# plugin that registered a subagent or used Knobs failed its OWN host-free smoke test.
218+
219+
220+
def test_default_stubs_expose_a_permissive_subagent_config():
221+
# #1764: graph.subagents.config.SubagentConfig must be a record-only stand-in that stores
222+
# its kwargs, so register_subagent(SubagentConfig(...)) runs host-free and stays assertable.
223+
specs = testkit._default_stubs()
224+
assert "graph.subagents.config" in specs and "graph.subagents" in specs # parent pkg too
225+
SubagentConfig = specs["graph.subagents.config"]["SubagentConfig"]
226+
sc = SubagentConfig(name="architect", description="d", system_prompt="p", tools=["read_file"])
227+
assert sc.name == "architect"
228+
assert sc.tools == ["read_file"]
229+
230+
231+
def test_default_stubs_expose_chainable_knobs_and_a_tool_list():
232+
# #1764: graph.sdk Knobs/make_knob_tools are called at register() time, so they must be
233+
# real stand-ins (chainable no-op + list), not the raise-when-called placeholder.
234+
specs = testkit._default_stubs()
235+
Knobs = specs["graph.sdk"]["Knobs"]
236+
make_knob_tools = specs["graph.sdk"]["make_knob_tools"]
237+
knobs = Knobs()
238+
assert knobs.define("depth", 3, lo=1, hi=5).preset("deep", {"depth": 5}) is knobs # chainable
239+
assert knobs.get("depth") == 3 # recorded default reads back
240+
tools = make_knob_tools(knobs, prefix="demo")
241+
assert isinstance(tools, list) and [t.name for t in tools] == ["demo_knobs", "demo_tune", "demo_preset"]
242+
243+
244+
def test_register_with_subagent_and_knobs_runs_against_the_stubs(monkeypatch):
245+
# The end-to-end shape #1764 fixes: a register() that registers a subagent (SubagentConfig)
246+
# AND wires knob tools (Knobs/make_knob_tools). In-repo the real graph host is importable, so
247+
# mask just the two leaf modules the register() imports with the STUBS — exercising exactly
248+
# the standalone code path a scaffolded plugin runs. monkeypatch restores sys.modules after.
249+
specs = testkit._default_stubs()
250+
for name in ("graph.sdk", "graph.subagents.config"):
251+
monkeypatch.setitem(sys.modules, name, testkit._StubModule(name, specs[name]))
252+
253+
def register(registry):
254+
from graph.sdk import Knobs, make_knob_tools
255+
from graph.subagents.config import SubagentConfig
256+
257+
knobs = Knobs().define("depth", 3, lo=1, hi=5).preset("deep", {"depth": 5})
258+
registry.register_tools(make_knob_tools(knobs, prefix="demo"))
259+
registry.register_subagent(
260+
SubagentConfig(
261+
name="architect",
262+
description="Designs the plugin.",
263+
system_prompt="You are the architect.",
264+
tools=["read_file"],
265+
)
266+
)
267+
268+
reg = testkit.FakeRegistry()
269+
register(reg) # must not raise — the whole point of #1764
270+
271+
assert len(reg.subagents) == 1 # the subagent contribution is recorded + assertable
272+
assert reg.subagents[0].name == "architect"
273+
assert reg.subagents[0].tools == ["read_file"]
274+
assert [t.name for t in reg.tools] == ["demo_knobs", "demo_tune", "demo_preset"] # knob tools wired

0 commit comments

Comments
 (0)