fix(runtime): raise on same-kind bundles claiming one class name - #117
Merged
Conversation
PluginRegistry._scan collected discovered classes into a plain dict keyed by class name, so two bundles of the same kind (two agents, or two workflows) exporting a class of the same name silently collapsed into one entry, in sorted bundle order — the classic "copied a bundle, forgot to rename the class" mistake vanished the original with no error. It now raises ConfigError naming both bundle paths and the shared class name. A bundle that raises while importing (SyntaxError, missing dependency) is also wrapped in a ConfigError naming its path instead of surfacing a raw traceback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sagi5060
marked this pull request as ready for review
August 6, 2026 09:20
Closed
4 tasks
…4 changelog Review round 2 on #82/PR #117: - The same-name guard was keyed on class name, so a bundle that binds its own class under a second name (an alias kept after a rename, e.g. `GreeterAgent = Greeter`) tripped the guard against itself with a self-contradicting message naming the same bundle path twice. `vars(module)` yields one entry per binding, not per class. Now keyed on identity (`claimant is not attr`), with a regression test pinning that one bundle aliasing its own class still loads as a single invocable. - The collision test's assertions were bare substrings ("agents/greeter" in message), which "agents/greeter-v2" alone satisfies — the fix's core guarantee (naming *both* bundles) was unpinned. Now asserts the exact quoted forms, which are not substrings of each other. - Restored the two already-released 2.0.0b4 CHANGELOG entries verbatim — the published release notes still carry the caveat this PR removes, so `dev` must not silently diverge from what was shipped. Only the new Unreleased entries describe current behavior. - Reworded the collision message to match the existing cross-kind message's style in discovery.py, and documented the collision/alias behavior on PluginRegistry's class docstring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CHANGELOG.md conflict resolved as a union, kept in Added/Fixed order per the file's own header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
Author
|
Review round 2 addressed — pushed to this branch, ready for re-review.
Also took the wording (
|
sagi5060
added a commit
that referenced
this pull request
Aug 6, 2026
…gress-reports One real conflict. #114 restructured the langgraph adapter: `start` and `resume` now both delegate to `_drive`, which is where the `RunnableConfig` is built and which v1's bridge wraps. So the reporter injection moves there — one line instead of two, and v1's workflow runs inherit it rather than being the one path whose nodes cannot report. Also records what the two node-level channels are for, now that they sit next to each other: #114's `get_stream_writer()` write is whatever a node chose, so it travels as a namespaced `custom`; status and progress are canonical kinds, which is D10's promotion rather than another `custom`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 6, 2026
sagi5060
added a commit
that referenced
this pull request
Aug 10, 2026
…failures A bundle defining only an AgentDeclaration/WorkflowDeclaration subclass and never instantiating it now fails Deck.from_project() naming the bundle file and what to add, instead of silently contributing nothing (#174) — the natural shape of a v1 bundle ported to v3, which scans for instances rather than subclasses. A discovered agent/workflow whose compile_agent()/ build_graph() raises now names its bundle path in a chained ConfigError, the same way #82/#117 already did for import failures (#119). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sagi5060
added a commit
that referenced
this pull request
Aug 10, 2026
…failures (#186) A bundle defining only an AgentDeclaration/WorkflowDeclaration subclass and never instantiating it now fails Deck.from_project() naming the bundle file and what to add, instead of silently contributing nothing (#174) — the natural shape of a v1 bundle ported to v3, which scans for instances rather than subclasses. A discovered agent/workflow whose compile_agent()/ build_graph() raises now names its bundle path in a chained ConfigError, the same way #82/#117 already did for import failures (#119). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
PluginRegistry._scan(agentdeck/runtime/registry.py) collected discovered classes intofound[attr.__name__] = attr, so two bundles of the same kind exporting the same classname deduped silently, in sorted bundle order. Copying
agents/greeter/toagents/greeter-v2/to iterate and forgetting to rename the class made the originalgreeterbundle vanish from the registry with no error — every request routed to that namesilently ran the other agent. Fixed at the point of discovery:
_scannow tracks whichbundle claimed each class name and raises
ConfigErrornaming both bundle paths and theshared class name the moment a second, different class claims a name already taken.
AgentRegistry,WorkflowRegistry,App.load(), andInvocableRegistry(which discoversthrough the same v1 scan) all inherit the fix for free.
Sibling nit from the same review (#82's Notes): a bundle whose module raises while
importing (
SyntaxError, a missing dependency) used to surface as a raw traceback throughthe import machinery, 12–14 frames deep. The import call in
_scanis now wrapped, so itraises
ConfigErrornaming the offending bundle path with the original exception chainedas the cause.
Refs #82 — Done-when item 2 ("import/build failures wrapped") is only half done: import is
wrapped,
build()/build_graph()is not (see judgment ledger and #119 below). Not closing#82 until that's tracked to completion or explicitly descoped there.
Judgment ledger
ConfigErroranda loud warning that keeps both under distinguished names. A silently missing agent (today's
bug) and a silently renamed agent (auto-disambiguating a warn-and-rename scheme) are the
same failure mode from a caller's perspective — a request that used to reach one agent now
silently reaches a different one, or an invented name nobody asked for. Raising is the only
option where a name-routing mistake can't hide. I looked for a legitimate layout the loud
version breaks and did not find one: v1 already keeps agents and workflows in disjoint
registries and disjoint
type_dirs, so two unrelated bundles that happen to pick the sameclass name for different concepts is exactly the case this issue exists to catch, not a
case to accommodate.
below):
vars(module)yields one entry per binding, not per class, so a bundle thataliases its own class under a second name (
GreeterAgent = Greeter, kept after a rename)must not trip the guard against itself. The check is
claimant is not None and claimant is not attr, notattr.__name__ in found.additive on the success path (a project with no name collisions, and no cross-bundle
aliasing, behaves identically) and turns one specific silent-data-loss case into a
load-time
ConfigError. One existing test(
tests/test_app.py::test_injected_session_factory_closed_when_load_fails) pinned the oldraw
RuntimeErrora broken bundle's import used to raise; updated to expect the newConfigErrorwrapping, since that behavior change is Notes item 2 of this same issue. Nogolden/SSE fixture exercises a same-kind collision, an alias, or an import failure, so
nothing else moved.
ConfigErrora bundle itself raises at module scope (not justSyntaxError/missingdep) is now re-wrapped too — the import
except Exceptionis broad by design, so abundle author's own
ConfigErrorraised during import gets caught and re-raised as a newConfigErrornaming the bundle path, with the original chained as__cause__. Type andmessage are preserved (nothing swallowed); the outer message is a superset (bundle path +
str(original)), so no caller matching on the message's start breaks — checked, nothing inthe codebase does.
importlib.import_modulecall inside
_scan— the failure mode the issue's Notes describes concretely(
SyntaxError, missing dep, a raw traceback through import machinery). Did not threadbundle-path context through
BaseAgent.build()/BaseWorkflow.build_graph()— those arecalled from several sites (
App.load(),InvocableRegistry.load(),run_agent, …) with nobundle-path association surviving past the scan (
PluginRegistry.list()returnsdict[str, type[T]], not a class-to-path mapping), andbuild()already has its ownConfigErrorpaths for user-facing misconfiguration. Threading bundle paths through everybuild()call site is a larger, separately-scoped change — filed as discovery: build()/build_graph() failures still surface a bare exception with no bundle path #119 rather thandone opportunistically here, and the PR now says
Refs #82instead ofCloses #82becauseof it.
f"two bundles under '{self.type_dir}/' both define the {self.label} class {attr.__name__!r}: '...' and '...'; one name is one invocable — rename one of the classes."— matches the lowercase, semicolon-joined style of the existing cross-kindmessage in
agentdeck/runtime/discovery.py's_addrather than inventing a new voice.PluginRegistry's class docstring now states the collision/alias behavior — itpreviously documented only laziness and caching, so the new failure mode (and the
non-failure alias case) wasn't discoverable from the class itself.
two already-released 2.0.0b4 entries (from PR feat(registry): discover invocables into an InvocableRegistry #81) that stated "two bundles of the same
kind ... still collapse to a single invocable" — verified via
gh release view v2.0.0b4that the published release notes still say this, so a b4 reader was told the truth as of
b4 and that section is the published artifact, not a living description of current
behavior (the file's own header: entries are "written to be attached to a release as-is").
dev's CHANGELOG now matches the release verbatim for that section; only the newUnreleased entries describe today's behavior.
Test evidence
Tests in
tests/test_errors.pyandtests/test_app.py:test_two_same_kind_bundles_sharing_a_class_name_raise_naming_both— two agent bundles(
greeter,greeter-v2) both definingclass Greeter; asserts the exact quoted bundlepaths (
'agents/greeter'and'agents/greeter-v2', not bare substrings — one is asubstring of the other, which is exactly how the previous version of this test passed
even when the message named only one bundle) both appear in the
ConfigErrormessage.test_one_bundle_aliasing_its_own_class_is_not_a_collision(new, review round 2) — onebundle with
GreeterAgent = Greeter; assertsApp().load()succeeds with exactly oneinvocable. Pins the false-positive fix.
test_bundle_import_failure_is_wrapped_with_its_path— a bundle that raisesRuntimeErrorat import now raises
ConfigErrornaming its path, with the original exception chained.test_injected_session_factory_closed_when_load_fails(existing, updated) — same importrepro, now asserting the wrapped
ConfigErrorinstead of the rawRuntimeError.Verified all of these have teeth by stashing the
registry.pyfix, rerunning — thecollision, alias, and import-wrapping tests all failed against the pre-fix code — then
restoring the fix and rerunning green.
make checkequivalent (freshpython3.12 -m venv+pip install -e ".[dev,serve,durability]",since the shared repo
.venv's editable install resolves to the main worktree, not thisone): ruff check — pass; ruff format --check — pass;
ty check agentdeck— pass;lint-imports— 12/12 contracts kept;pytest tests/— 688 passed, 96 skipped (expected:optional Redis/Postgres backends without services running), 0 failed. Re-ran after merging
origin/devin (brought in #112's control-lifecycle events, unrelated to this PR;CHANGELOG.mdconflict resolved as a union) — still fully green.Review round 2
Addressed request-changes review:
class (
GreeterAgent = Greeter) tripped the guard against itself with aself-contradicting message naming the same bundle path twice. Now identity-based
(
claimant is not attr). Added the false-positive regression test.(
"agents/greeter" in message), satisfied even if the message named onlyagents/greeter-v2. Now asserts the exact quoted forms, which are not substrings of eachother.
verbatim —
devand the publishedv2.0.0b4release notes now agree again. Only the newUnreleased entries describe current behavior.
build()/build_graph()half of "import/buildfailures are wrapped", and changed
Closes #82toRefs #82since that Done-when item isonly half satisfied by this PR.
Also took the wording/docstring advisories and added the re-wrapped-
ConfigErrorledgerline above.
🤖 Generated with Claude Code