Skip to content

fix(ai): getNodeConfig drops top-level keys and delivers arrays as IJson when profile is set - #2039

Merged
dylan-savage merged 6 commits into
rocketride-org:developfrom
nishant-k02:fix/RR-1839-getnodeconfig-top-level-keys
Sep 3, 2026
Merged

dylan-savage merged 6 commits into
rocketride-org:developfrom
nishant-k02:fix/RR-1839-getnodeconfig-top-level-keys

Conversation

@nishant-k02

@nishant-k02 nishant-k02 commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes both defects from #1839.

Defect 1 — with an explicit "profile" key set, getNodeConfig only read connConfig[profile], silently dropping every key the caller wrote at the top level (e.g. an "apikey" sitting alongside "profile" instead of nested under the profile name). The sibling branch (no "profile" key) already overlays top-level keys on top of a nested profile-named block — the two branches disagreed about where configuration could live depending on whether "profile" happened to be set.

Defect 2 — array/object config values arrive from the engine as IJson, not list/dict, so a node that type-checks its own declared field (isinstance(value, list)) silently falls through to its default and reports success while ignoring what was configured.

Fix

  • Extracted the top-level/nested overlay into a shared overlay_top_level helper and used it in both branches, so a key written at the top level is honoured regardless of whether "profile" is set. The "profile" selector key itself is explicitly excluded so it can't leak into the resolved config.
  • getNodeConfig now returns IJson.toDict(config) instead of the raw config. Verified against the real rocketlib.types.IJson.toDict (recursive, no-op on already-native values) that this is safe to apply unconditionally at the return boundary rather than pushing the conversion onto every node.

Precedence, both branches now agree: profile defaults < nested profile block < real top-level values (top-level wins).

A note on #1979

I noticed #1979 (open, same files) after starting this PR. It fixes the same reported symptom but takes the opposite precedence — profile-specific (nested) keys win over top-level — and only touches the explicit-profile branch, not the default-profile branch. Since the default-profile branch's existing, currently-shipped, tested behavior already has top-level winning over nested (test_top_level_overrides_nested_value, test_real_top_level_beats_empty_nested_default in test_config_shapes.py), #1979's change would leave the two branches disagreeing again — just with the opposite branch now being the odd one out, which is the exact meta-problem #1839 raised ("the two branches disagree about where a user's configuration lives"). I chose to match the already-shipped precedent instead of the issue's literal suggested text, so the fix is a pure bug fix rather than a behavior change to already-shipped, tested resolution. Flagging this explicitly since it's a genuine design decision the issue author (Aryan-070) also called out as unresolved — happy to adjust if maintainers prefer #1979's direction, but in that case the default-profile branch would need the same precedence flip for consistency, which is a larger, more disruptive change to existing behavior.

Testing performed

  • TestExplicitProfileBranchTopLevelFields (4 tests, defect 1) — top-level fields resolve with an explicit profile, top-level overrides a nested value, a None top-level placeholder doesn't clobber nested, and "profile" itself doesn't leak into the resolved config.
  • TestNativeConfigTypes (3 tests, defect 2) — an IJson-wrapped array resolves to a native list, nested IJson-wrapped objects resolve recursively, and an already-native default value is unaffected. Upgraded the test file's IJson stub from a shallow dict-only shim to one that actually wraps a value and recurses, so these tests exercise real conversion behavior rather than a no-op.
  • Renamed TestExplicitProfileBranchUnaffected → TestExplicitProfileBranchNestedShape since its docstring/name asserted the branch was unaffected by config-shape handling, which is no longer accurate.
  • Full suite: 21/21 passed, including every pre-existing test (no regressions to the already-fixed default-profile branch or nested-shape resolution).
pytest packages/ai/tests/ai/common/test_config_shapes.py --noconftest -v
======================== 21 passed, 1 warning in 0.02s ========================

How I found this

Building an app against RocketRide Cloud, I configured an llm_anthropic node as:

{ "profile": "claude-sonnet-4-6", "apikey": "sk-ant-..." }

and got Invalid Anthropic API key format even though the key was valid — the apikey value was silently discarded. Nesting the same key under connConfig["claude-sonnet-4-6"] worked, which is exactly the shape described in #1839 defect 1.

Breaking changes

None for existing shapes. Flat config (no profile) and nested-under-profile config continue to resolve exactly as before; this adds resolution for a shape (top-level keys + explicit profile) that previously silently failed, and normalizes array/object return values to native types (previously-broken isinstance checks in nodes will now correctly see their configured values instead of silently falling back to defaults).

Fixes #1839

Summary by CodeRabbit

  • Bug Fixes

    • Improved configuration resolution for explicit and default profiles.
    • Top-level settings now correctly override profile values when provided, while unset values preserve profile defaults.
    • Scalar and list settings correctly replace object defaults, while dictionary values continue to merge recursively.
    • The profile selector is excluded from resolved configuration.
    • Nested arrays and objects are consistently converted to native types.
  • Tests

    • Expanded coverage for precedence, nested resolution, selector exclusion, unset values, recursive conversion, and type-specific overrides.

When connConfig has an explicit "profile" key, getNodeConfig only read
connConfig[profile], ignoring every key the caller wrote at the top
level (e.g. an "apikey" alongside "profile" instead of nested under
the profile name). The other branch (no "profile" key) already
overlays top-level keys on top of a nested profile-named block; the
two branches disagreed about where a caller's configuration could
live depending on whether "profile" happened to be set.

Extract that overlay into a shared helper (overlay_top_level) and use
it in both branches, so a key written at the top level is honoured
whether or not "profile" is set, and both branches agree on
precedence: profile defaults < nested profile block < real top-level
values. The "profile" selector key itself is now explicitly excluded
so it can't leak into the resolved config.

Confirmed independently against a real pipeline: an llm_anthropic
node configured with {"profile": "claude-sonnet-4-6", "apikey": "..."}
(flat) silently received no API key, while nesting the same apikey
under connConfig["claude-sonnet-4-6"] worked - reproducing exactly
what's described in rocketride-org#1839 defect 1.

Adds TestExplicitProfileBranchTopLevelFields covering the fix, and
renames the test class that pinned the old (dropped) behavior to
TestExplicitProfileBranchNestedShape since it no longer stands alone.

Fixes rocketride-org#1839
@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: c158c318-dfb2-458c-a639-f650c68cc3c4

📥 Commits

Reviewing files that changed from the base of the PR and between 5215154 and cfd355b.

📒 Files selected for processing (2)
  • packages/ai/src/ai/common/config.py
  • packages/ai/tests/ai/common/test_config_shapes.py

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

getNodeConfig now shares top-level overlay logic for explicit and default profiles. It preserves nested values when top-level values are None, excludes selector keys, and normalizes resolved IJson values. Tests cover precedence, recursive conversion, and native-list preservation.

Changes

Profile configuration resolution

Layer / File(s) Summary
Shared configuration overlay
packages/ai/src/ai/common/config.py
Adds overlay_top_level and routes both profile paths through it. The resolver merges profile defaults, nested values, and eligible top-level values, then applies IJson.toDict.
Configuration shape regression coverage
packages/ai/tests/ai/common/test_config_shapes.py
Adds tests for explicit-profile precedence, None handling, selector removal, recursive conversion of wrapped arrays and objects, native-list preservation, and scalar or list overrides.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to cfd35

Configuration now consistently applies top-level overrides for explicit profiles and returns native Python collection types. The covered behavior is ready to merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies both fixes in the pull request: preserving top-level configuration keys when a profile is set and converting returned arrays to native values.
Linked Issues check ✅ Passed The pull request addresses both defects in issue #1839. It applies top-level configuration consistently for explicit and default profiles, preserves the stated precedence, excludes the profile selecto…
Out of Scope Changes check ✅ Passed The changes remain within issue #1839. The implementation updates getNodeConfig, simplifies related merge logic, and adds focused tests for profile overlays, precedence, and native type conversion. No…
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 35 functions across 2 files.
Full details: Linked Issues check

Explanation

The pull request addresses both defects in issue #1839. It applies top-level configuration consistently for explicit and default profiles, preserves the stated precedence, excludes the profile selector, and recursively converts returned IJson values to native Python types. The tests cover the required precedence, None handling, selector exclusion, and recursive conversion.

Full details: Out of Scope Changes check

Explanation

The changes remain within issue #1839. The implementation updates getNodeConfig, simplifies related merge logic, and adds focused tests for profile overlays, precedence, and native type conversion. No unrelated production behavior is changed.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Addresses rocketride-org#1839 defect 2: array/object config values arrive from the
engine as IJson, not list/dict, so a node that type-checks its own
declared field (isinstance(value, list)) silently falls through to
its default. IJson.toDict already does the recursive conversion and
is a no-op on values that are already native, so apply it once at the
return boundary rather than pushing the conversion onto every node,
per the issue's own suggested direction.

Adds TestNativeConfigTypes (3 tests) and upgrades the test file's
IJson stub from a shallow dict-only shim to one that actually wraps a
value and recurses, matching the real rocketlib.types.IJson.toDict
closely enough to exercise this. Also adds docstrings to the defect-1
tests to close out the docstring-coverage gap flagged in review.

Full suite: 21/21 passed (up from 18; +3 new).
@nishant-k02 nishant-k02 changed the title fix(ai): getNodeConfig drops top-level keys when profile is set fix(ai): getNodeConfig drops top-level keys and delivers arrays as IJson when profile is set Aug 18, 2026
Closes out the docstring-coverage check, which scores the whole file:
the pre-existing test methods (written before this PR) had none,
relying on their names alone. Every function in the file is now
documented; no behavior change.
Covers the four test classes (TestFlatShape, TestNestedShape,
TestMixedShapePrecedence, TestExplicitProfileBranchNestedShape) that
had per-method docstrings but no class-level summary of which
connConfig shape they exercise. File is now fully documented at both
the class and function level (35/35). No behavior change.
rajeev-chaurasia added a commit to rajeev-chaurasia/rocketride-server that referenced this pull request Aug 28, 2026
Both are real defects the PR's own additions found in shipped examples.

document-processor names provider "anonymize", but that node declares
"protocol": "anonymize_text://" and the provider is the protocol minus the
scheme, so no such service exists and the component could never instantiate.
The new provider check in validate_pipeline flags it; the old validator
resolved the definition and silently ignored the miss.

exa-search-working carries apikey beside "profile", where getNodeConfig never
reads it, so the pipe named "working" resolved its key to the profile's empty
default. Moving it inside the profile object is correct under the current
resolver and under the one rocketride-org#2039 and rocketride-org#1979 propose. resolve_config reports it.

Over every pipeline in the repo, resolve_config now reports no discarded keys.
joshuadarron pushed a commit that referenced this pull request Aug 28, 2026
…in validate_pipeline (#2082)

* feat(mcp): add resolve_config, showing what a node actually receives

A .pipe rarely says what a node gets. The engine merges profile defaults and,
when a profile is set, reads the user layer only from the sub-object named after
that profile, so a key written beside it is discarded in silence. The author of
#1989 lost a day to exactly that, and the schema cannot express it: a schema
describes what a field is, not that this field in this profile will be dropped.

resolve_config runs the config through the same Config.getNodeConfig a node calls
at load, and returns the resolved result plus the keys the resolver discarded,
with a hint naming the object they belong in.

Resolution has to happen engine-side. getServiceSchemas copies 15 keys into the
catalog and preconfig is not among them (services.cpp:2100-2148), so profile
resolution cannot be reproduced from rrext_services or /services.

The tool reaches the engine through the existing seam rather than importing
Config directly. modules/mcp/engine.py states that tools depend only on the
EngineClient protocol so a later revision can swap the WS SDK for in-process
calls without touching tool code; a direct import would have made this the first
tool to break that. So this adds rrext_resolve_config next to rrext_services and
rrext_validate, the matching SDK method, and one line on each side of the seam.

Both argument checks default only a genuinely absent config: `or {}` would
coerce a falsy non-object such as [] and skip the type check entirely. A test
caught that.

doc.md gains the tool row and its count moves to 28.

* feat(mcp): add scaffold_node, emitting a node that loads on the first try

Authoring a node means discovering its contract by failure, and the documented
contract is wrong in three places, so reading the docs first does not help:

- preconfig is marked optional in README-node-schema.md, but
  Config.getNodeConfig raises without it and nearly every IGlobal calls that at
  load. Of 170 services*.json in the tree, the only ones without preconfig are
  global-field files with no protocol plus a handful whose IGlobal never calls
  it.
- depends() is documented as running in __init__.py, and the same page then
  contradicts itself. The real nodes call it from IGlobal.beginGlobal, and any
  third-party import has to follow it.
- The "Adding a New Node" example shows a plain class with a process() method,
  which nothing in the engine calls. The contract is IGlobalBase/IInstanceBase.

Two more the docs omit: a services.json without protocol is not registered as a
service at all, and register must be filter or endpoint or the node loads but
can never be instantiated. The provider name is the protocol, which is the
mistake the issue opens with.

scaffold_node emits all of that correctly, including the parent
local_nodes/__init__.py and the local_nodes.<name> import path. class_type and
lane_out are checked against the live catalog rather than a list here, so the
allowed sets follow the engine instead of drifting as nodes are added.

It returns files rather than writing them. _common.load_pipeline already refuses
server-side reads because an MCP caller could otherwise reach the server's disk;
writing deserves the same stance.

The tests assert the emitted skeleton against the engine's real requirements:
every generated Python file parses, the manifest carries the four keys that stop
a node loading, depends() is in IGlobal and not __init__.py, and the handler
matches the requested lane. Dropping preconfig from the template fails them.

doc.md gains the tool row and its count moves to 29.

* fix(client-python): let a non-object config reach the engine's type check

resolve_config defaulted with `config or {}`, which coerces a falsy non-object
such as [] into {} and hides the mistake the engine would have reported. Only an
absent config is defaulted now, matching the handler it calls.

* fix(mcp): report a config key the profile overwrote, not just an absent one

resolve_config decided a key was discarded by checking whether it was missing
from the resolved config. Nearly every profile in the catalog declares apikey,
so an apikey written beside "profile" comes back present, holding the profile's
value rather than the author's, and the tool stayed silent on the exact case it
exists to surface.

A key now counts as discarded when what the node receives differs from what the
author wrote. Found by calling the tool over the MCP transport against a real
llm_openai profile; the unit test missed it because its stub returned a resolved
config without the key at all.

* refactor(mcp): give the catalog lookups one job each

_catalog_values branched on whether its key argument was the string 'lanes',
so one function carried two different traversals. Splitting them drops the
branch and the stringly-typed parameter, and the lane names are now filtered
for strings the way the class types already were.

* fix(mcp): harden scaffold_node against catalog and argument shapes

Three defects found in review:

A catalog entry whose classType is a bare string was iterated character by
character, so 'text' admitted 't', 'e' and 'x' as class types and rejected the
real one. Introduced when the lookup was split in two; the container check that
guarded it is back.

Dispatch does not apply the tool's inputSchema, so a non-string name reached the
regex and raised TypeError instead of returning an actionable answer. Name, both
lanes and class_type are now checked for type first.

lane_in was validated only against the local handler templates, never against
the catalog, so a lane with a template but no engine support produced a manifest
with an input lane nothing serves.

Also corrects the introspection group heading in doc.md, which still counted 5
tools and did not list scaffold.py as a source.

* fix(mcp): report every profile sibling key, not only one whose value changed

Comparing the resolved value against the author's missed the sibling that
happens to match the profile's own value. The resolver never read that line
either, so staying quiet left the author believing it was in effect when the
key inside the profile is what applied.

With a profile set every sibling is discarded, so the value is not worth
comparing and the check is simpler for it.

* fix(mcp): keep a supplied falsy argument out of the scaffold defaults

lane_in, lane_out and class_type defaulted with `or`, so a supplied 0, '' or []
became the fallback and never reached the type check that would have reported
it. Defaulting now applies only to an absent or null argument, matching the
handler this PR already corrected on the client side.

* feat(mcp): flag a component whose provider the engine cannot serve

validate_pipeline passed a pipeline naming a provider that does not exist, so a
typed name validated clean and failed later at run time. The engine's provider
lookup lives in validate_pipeline.cpp's single-component path, which the whole
pipeline path never reaches.

The tool now checks each component against the engine's service catalog after
the engine's own validation, and reports the component id with the name it could
not resolve. The names come from the engine, so no rule moves into the client,
and engine findings are added to rather than replaced.

Closes the third of the four tools #1989 asked for; it was previously treated as
already shipped in #1880, which covers the structural half only.

* fix(mcp): report only the config keys a profile actually discarded

resolve_config reported every sibling of the selected profile, so it named
parameters on all 34 components in the repo's own pipelines that select one,
and told the author to move it inside the profile object. parameters is not
node configuration: the engine validates and consumes it on its own path
(pipeline_config.cpp Rule 5 and Rule 6), and moving it would break the
component. name is the same shape, and an editor-saved config keeps a
sub-object per profile, so an unselected profile read as a discarded key too.

Structural keys and the service's own profile names are now excluded. Over the
same pipelines the report goes from 34 components to 1, and that one is a real
finding: search_exa carries apikey beside the profile.

The existing test passed because its config was hand-written with only apikey
beside the profile. The new one is shaped like a real .pipe.

Also drops the return in the scaffolded handler's preventDefault call, which
raises, matching how the real nodes write it.

* fix(examples): repair the two component configs these tools surfaced

Both are real defects the PR's own additions found in shipped examples.

document-processor names provider "anonymize", but that node declares
"protocol": "anonymize_text://" and the provider is the protocol minus the
scheme, so no such service exists and the component could never instantiate.
The new provider check in validate_pipeline flags it; the old validator
resolved the definition and silently ignored the miss.

exa-search-working carries apikey beside "profile", where getNodeConfig never
reads it, so the pipe named "working" resolved its key to the profile's empty
default. Moving it inside the profile object is correct under the current
resolver and under the one #2039 and #1979 propose. resolve_config reports it.

Over every pipeline in the repo, resolve_config now reports no discarded keys.
@nihalnihalani

Copy link
Copy Markdown
Collaborator

Unifying both branches behind overlay_top_level() is the right shape — it keeps the explicit-profile branch consistent with the precedence the default branch already uses (config.py:257), which #1979, the other open fix for this, inverts.

Two things before it can land:

Separately, #1806 is adding a placeholder-resolution step at the same return point in getNodeConfig — worth a look so the two don't fight over ordering.

Resolves the conflict with rocketride-org#2166, which added Config._warnMisnamedKeys()
to both getNodeConfig branches after this PR collapsed them into a single
path. One call now sits on the unified path, so the warning still fires --
and unlike develop it also sees top-level keys on the explicit-profile
branch.

Two fixes on top of the merge:

- ruff format: unwrap a call that fits on one line, and rephrase a
  docstring that opened with a quote character. The CI Ruff job was
  failing on `ruff format --check`.

- merge(): require both sides to be dict-like before recursing. A scalar
  or list written over an object-valued profile default otherwise reaches
  .items() and raises AttributeError. Develop already crashes this way on
  the default-profile branch, and unifying the branches would have widened
  it to the explicit-profile branch. 94 dict-valued profile defaults ship
  today -- every LLM node's "capabilities", schema_validate's
  "category_metric_map", normalize_facts' "label_to_metric", autopipe's
  "remote" -- so this is reachable rather than theoretical. Four
  regression tests cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gbzJyEdgWhSHPgswzw5L
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

rocketlib's IJson subclasses the engLib IJson the engine actually
constructs, and config.py imports the subclass. Every value the engine
produces is a base-class instance, so isinstance(value, IJson) is False
in production.

overlay_top_level() type-tested the nested profile block on that check,
so it took the else branch and used an empty dict -- discarding the whole
block. The node test harness builds exactly that shape
({"profile": "default", "default": {"apikey": ...}}), which is why every
node with mock credentials failed with "apikey is required", and why the
lane guardrail failed with "Model name was provided but is empty".

Develop carries the same broken check on its default-profile branch, but
harmlessly: when it fails it falls back to using connConfig whole. This
PR made the check the only path in, so failing it lost the config.

Normalize with IJson.toDict() first -- it tests against the correct base
class and is a no-op on already-native values -- then inspect the result.
This also removes the dict(<list>) path that raised
"dictionary update sequence element #0 has length 27".

The unit tests missed all of this because the _FakeIJson stub was a
single flat class that isinstance matched. Split it into _EngineIJson
(the engLib stand-in the engine constructs) and _FakeIJson (the rocketlib
subclass config.py imports) so the trap is reproducible, and add
TestEngineSuppliedConfig. Two of those four fail against the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gbzJyEdgWhSHPgswzw5L
@dylan-savage
dylan-savage merged commit 1dade97 into rocketride-org:develop Sep 3, 2026
20 checks passed
dylan-savage added a commit to Aryan-070/rocketride-server that referenced this pull request Sep 4, 2026
…son when profile is set (rocketride-org#2039)

* fix(ai): getNodeConfig drops top-level keys when profile is set

When connConfig has an explicit "profile" key, getNodeConfig only read
connConfig[profile], ignoring every key the caller wrote at the top
level (e.g. an "apikey" alongside "profile" instead of nested under
the profile name). The other branch (no "profile" key) already
overlays top-level keys on top of a nested profile-named block; the
two branches disagreed about where a caller's configuration could
live depending on whether "profile" happened to be set.

Extract that overlay into a shared helper (overlay_top_level) and use
it in both branches, so a key written at the top level is honoured
whether or not "profile" is set, and both branches agree on
precedence: profile defaults < nested profile block < real top-level
values. The "profile" selector key itself is now explicitly excluded
so it can't leak into the resolved config.

Confirmed independently against a real pipeline: an llm_anthropic
node configured with {"profile": "claude-sonnet-4-6", "apikey": "..."}
(flat) silently received no API key, while nesting the same apikey
under connConfig["claude-sonnet-4-6"] worked - reproducing exactly
what's described in rocketride-org#1839 defect 1.

Adds TestExplicitProfileBranchTopLevelFields covering the fix, and
renames the test class that pinned the old (dropped) behavior to
TestExplicitProfileBranchNestedShape since it no longer stands alone.

Fixes rocketride-org#1839

* fix(ai): normalize getNodeConfig's return value with IJson.toDict

Addresses rocketride-org#1839 defect 2: array/object config values arrive from the
engine as IJson, not list/dict, so a node that type-checks its own
declared field (isinstance(value, list)) silently falls through to
its default. IJson.toDict already does the recursive conversion and
is a no-op on values that are already native, so apply it once at the
return boundary rather than pushing the conversion onto every node,
per the issue's own suggested direction.

Adds TestNativeConfigTypes (3 tests) and upgrades the test file's
IJson stub from a shallow dict-only shim to one that actually wraps a
value and recurses, matching the real rocketlib.types.IJson.toDict
closely enough to exercise this. Also adds docstrings to the defect-1
tests to close out the docstring-coverage gap flagged in review.

Full suite: 21/21 passed (up from 18; +3 new).

* docs(ai): add docstrings to remaining test methods in test_config_shapes

Closes out the docstring-coverage check, which scores the whole file:
the pre-existing test methods (written before this PR) had none,
relying on their names alone. Every function in the file is now
documented; no behavior change.

* docs(ai): add class-level docstrings to remaining test classes

Covers the four test classes (TestFlatShape, TestNestedShape,
TestMixedShapePrecedence, TestExplicitProfileBranchNestedShape) that
had per-method docstrings but no class-level summary of which
connConfig shape they exercise. File is now fully documented at both
the class and function level (35/35). No behavior change.

* fix(ai): read the nested profile block via IJson.toDict, not isinstance

rocketlib's IJson subclasses the engLib IJson the engine actually
constructs, and config.py imports the subclass. Every value the engine
produces is a base-class instance, so isinstance(value, IJson) is False
in production.

overlay_top_level() type-tested the nested profile block on that check,
so it took the else branch and used an empty dict -- discarding the whole
block. The node test harness builds exactly that shape
({"profile": "default", "default": {"apikey": ...}}), which is why every
node with mock credentials failed with "apikey is required", and why the
lane guardrail failed with "Model name was provided but is empty".

Develop carries the same broken check on its default-profile branch, but
harmlessly: when it fails it falls back to using connConfig whole. This
PR made the check the only path in, so failing it lost the config.

Normalize with IJson.toDict() first -- it tests against the correct base
class and is a no-op on already-native values -- then inspect the result.
This also removes the dict(<list>) path that raised
"dictionary update sequence element #0 has length 27".

The unit tests missed all of this because the _FakeIJson stub was a
single flat class that isinstance matched. Split it into _EngineIJson
(the engLib stand-in the engine constructs) and _FakeIJson (the rocketlib
subclass config.py imports) so the trap is reproducible, and add
TestEngineSuppliedConfig. Two of those four fail against the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gbzJyEdgWhSHPgswzw5L

---------

Co-authored-by: Dylan Savage <dylanmsavage@hotmail.com>
erichanwang pushed a commit to erichanwang/rocketride-server that referenced this pull request Sep 7, 2026
…in validate_pipeline (rocketride-org#2082)

* feat(mcp): add resolve_config, showing what a node actually receives

A .pipe rarely says what a node gets. The engine merges profile defaults and,
when a profile is set, reads the user layer only from the sub-object named after
that profile, so a key written beside it is discarded in silence. The author of
rocketride-org#1989 lost a day to exactly that, and the schema cannot express it: a schema
describes what a field is, not that this field in this profile will be dropped.

resolve_config runs the config through the same Config.getNodeConfig a node calls
at load, and returns the resolved result plus the keys the resolver discarded,
with a hint naming the object they belong in.

Resolution has to happen engine-side. getServiceSchemas copies 15 keys into the
catalog and preconfig is not among them (services.cpp:2100-2148), so profile
resolution cannot be reproduced from rrext_services or /services.

The tool reaches the engine through the existing seam rather than importing
Config directly. modules/mcp/engine.py states that tools depend only on the
EngineClient protocol so a later revision can swap the WS SDK for in-process
calls without touching tool code; a direct import would have made this the first
tool to break that. So this adds rrext_resolve_config next to rrext_services and
rrext_validate, the matching SDK method, and one line on each side of the seam.

Both argument checks default only a genuinely absent config: `or {}` would
coerce a falsy non-object such as [] and skip the type check entirely. A test
caught that.

doc.md gains the tool row and its count moves to 28.

* feat(mcp): add scaffold_node, emitting a node that loads on the first try

Authoring a node means discovering its contract by failure, and the documented
contract is wrong in three places, so reading the docs first does not help:

- preconfig is marked optional in README-node-schema.md, but
  Config.getNodeConfig raises without it and nearly every IGlobal calls that at
  load. Of 170 services*.json in the tree, the only ones without preconfig are
  global-field files with no protocol plus a handful whose IGlobal never calls
  it.
- depends() is documented as running in __init__.py, and the same page then
  contradicts itself. The real nodes call it from IGlobal.beginGlobal, and any
  third-party import has to follow it.
- The "Adding a New Node" example shows a plain class with a process() method,
  which nothing in the engine calls. The contract is IGlobalBase/IInstanceBase.

Two more the docs omit: a services.json without protocol is not registered as a
service at all, and register must be filter or endpoint or the node loads but
can never be instantiated. The provider name is the protocol, which is the
mistake the issue opens with.

scaffold_node emits all of that correctly, including the parent
local_nodes/__init__.py and the local_nodes.<name> import path. class_type and
lane_out are checked against the live catalog rather than a list here, so the
allowed sets follow the engine instead of drifting as nodes are added.

It returns files rather than writing them. _common.load_pipeline already refuses
server-side reads because an MCP caller could otherwise reach the server's disk;
writing deserves the same stance.

The tests assert the emitted skeleton against the engine's real requirements:
every generated Python file parses, the manifest carries the four keys that stop
a node loading, depends() is in IGlobal and not __init__.py, and the handler
matches the requested lane. Dropping preconfig from the template fails them.

doc.md gains the tool row and its count moves to 29.

* fix(client-python): let a non-object config reach the engine's type check

resolve_config defaulted with `config or {}`, which coerces a falsy non-object
such as [] into {} and hides the mistake the engine would have reported. Only an
absent config is defaulted now, matching the handler it calls.

* fix(mcp): report a config key the profile overwrote, not just an absent one

resolve_config decided a key was discarded by checking whether it was missing
from the resolved config. Nearly every profile in the catalog declares apikey,
so an apikey written beside "profile" comes back present, holding the profile's
value rather than the author's, and the tool stayed silent on the exact case it
exists to surface.

A key now counts as discarded when what the node receives differs from what the
author wrote. Found by calling the tool over the MCP transport against a real
llm_openai profile; the unit test missed it because its stub returned a resolved
config without the key at all.

* refactor(mcp): give the catalog lookups one job each

_catalog_values branched on whether its key argument was the string 'lanes',
so one function carried two different traversals. Splitting them drops the
branch and the stringly-typed parameter, and the lane names are now filtered
for strings the way the class types already were.

* fix(mcp): harden scaffold_node against catalog and argument shapes

Three defects found in review:

A catalog entry whose classType is a bare string was iterated character by
character, so 'text' admitted 't', 'e' and 'x' as class types and rejected the
real one. Introduced when the lookup was split in two; the container check that
guarded it is back.

Dispatch does not apply the tool's inputSchema, so a non-string name reached the
regex and raised TypeError instead of returning an actionable answer. Name, both
lanes and class_type are now checked for type first.

lane_in was validated only against the local handler templates, never against
the catalog, so a lane with a template but no engine support produced a manifest
with an input lane nothing serves.

Also corrects the introspection group heading in doc.md, which still counted 5
tools and did not list scaffold.py as a source.

* fix(mcp): report every profile sibling key, not only one whose value changed

Comparing the resolved value against the author's missed the sibling that
happens to match the profile's own value. The resolver never read that line
either, so staying quiet left the author believing it was in effect when the
key inside the profile is what applied.

With a profile set every sibling is discarded, so the value is not worth
comparing and the check is simpler for it.

* fix(mcp): keep a supplied falsy argument out of the scaffold defaults

lane_in, lane_out and class_type defaulted with `or`, so a supplied 0, '' or []
became the fallback and never reached the type check that would have reported
it. Defaulting now applies only to an absent or null argument, matching the
handler this PR already corrected on the client side.

* feat(mcp): flag a component whose provider the engine cannot serve

validate_pipeline passed a pipeline naming a provider that does not exist, so a
typed name validated clean and failed later at run time. The engine's provider
lookup lives in validate_pipeline.cpp's single-component path, which the whole
pipeline path never reaches.

The tool now checks each component against the engine's service catalog after
the engine's own validation, and reports the component id with the name it could
not resolve. The names come from the engine, so no rule moves into the client,
and engine findings are added to rather than replaced.

Closes the third of the four tools rocketride-org#1989 asked for; it was previously treated as
already shipped in rocketride-org#1880, which covers the structural half only.

* fix(mcp): report only the config keys a profile actually discarded

resolve_config reported every sibling of the selected profile, so it named
parameters on all 34 components in the repo's own pipelines that select one,
and told the author to move it inside the profile object. parameters is not
node configuration: the engine validates and consumes it on its own path
(pipeline_config.cpp Rule 5 and Rule 6), and moving it would break the
component. name is the same shape, and an editor-saved config keeps a
sub-object per profile, so an unselected profile read as a discarded key too.

Structural keys and the service's own profile names are now excluded. Over the
same pipelines the report goes from 34 components to 1, and that one is a real
finding: search_exa carries apikey beside the profile.

The existing test passed because its config was hand-written with only apikey
beside the profile. The new one is shaped like a real .pipe.

Also drops the return in the scaffolded handler's preventDefault call, which
raises, matching how the real nodes write it.

* fix(examples): repair the two component configs these tools surfaced

Both are real defects the PR's own additions found in shipped examples.

document-processor names provider "anonymize", but that node declares
"protocol": "anonymize_text://" and the provider is the protocol minus the
scheme, so no such service exists and the component could never instantiate.
The new provider check in validate_pipeline flags it; the old validator
resolved the definition and silently ignored the miss.

exa-search-working carries apikey beside "profile", where getNodeConfig never
reads it, so the pipe named "working" resolved its key to the profile's empty
default. Moving it inside the profile object is correct under the current
resolver and under the one rocketride-org#2039 and rocketride-org#1979 propose. resolve_config reports it.

Over every pipeline in the repo, resolve_config now reports no discarded keys.
erichanwang pushed a commit to erichanwang/rocketride-server that referenced this pull request Sep 7, 2026
…son when profile is set (rocketride-org#2039)

* fix(ai): getNodeConfig drops top-level keys when profile is set

When connConfig has an explicit "profile" key, getNodeConfig only read
connConfig[profile], ignoring every key the caller wrote at the top
level (e.g. an "apikey" alongside "profile" instead of nested under
the profile name). The other branch (no "profile" key) already
overlays top-level keys on top of a nested profile-named block; the
two branches disagreed about where a caller's configuration could
live depending on whether "profile" happened to be set.

Extract that overlay into a shared helper (overlay_top_level) and use
it in both branches, so a key written at the top level is honoured
whether or not "profile" is set, and both branches agree on
precedence: profile defaults < nested profile block < real top-level
values. The "profile" selector key itself is now explicitly excluded
so it can't leak into the resolved config.

Confirmed independently against a real pipeline: an llm_anthropic
node configured with {"profile": "claude-sonnet-4-6", "apikey": "..."}
(flat) silently received no API key, while nesting the same apikey
under connConfig["claude-sonnet-4-6"] worked - reproducing exactly
what's described in rocketride-org#1839 defect 1.

Adds TestExplicitProfileBranchTopLevelFields covering the fix, and
renames the test class that pinned the old (dropped) behavior to
TestExplicitProfileBranchNestedShape since it no longer stands alone.

Fixes rocketride-org#1839

* fix(ai): normalize getNodeConfig's return value with IJson.toDict

Addresses rocketride-org#1839 defect 2: array/object config values arrive from the
engine as IJson, not list/dict, so a node that type-checks its own
declared field (isinstance(value, list)) silently falls through to
its default. IJson.toDict already does the recursive conversion and
is a no-op on values that are already native, so apply it once at the
return boundary rather than pushing the conversion onto every node,
per the issue's own suggested direction.

Adds TestNativeConfigTypes (3 tests) and upgrades the test file's
IJson stub from a shallow dict-only shim to one that actually wraps a
value and recurses, matching the real rocketlib.types.IJson.toDict
closely enough to exercise this. Also adds docstrings to the defect-1
tests to close out the docstring-coverage gap flagged in review.

Full suite: 21/21 passed (up from 18; +3 new).

* docs(ai): add docstrings to remaining test methods in test_config_shapes

Closes out the docstring-coverage check, which scores the whole file:
the pre-existing test methods (written before this PR) had none,
relying on their names alone. Every function in the file is now
documented; no behavior change.

* docs(ai): add class-level docstrings to remaining test classes

Covers the four test classes (TestFlatShape, TestNestedShape,
TestMixedShapePrecedence, TestExplicitProfileBranchNestedShape) that
had per-method docstrings but no class-level summary of which
connConfig shape they exercise. File is now fully documented at both
the class and function level (35/35). No behavior change.

* fix(ai): read the nested profile block via IJson.toDict, not isinstance

rocketlib's IJson subclasses the engLib IJson the engine actually
constructs, and config.py imports the subclass. Every value the engine
produces is a base-class instance, so isinstance(value, IJson) is False
in production.

overlay_top_level() type-tested the nested profile block on that check,
so it took the else branch and used an empty dict -- discarding the whole
block. The node test harness builds exactly that shape
({"profile": "default", "default": {"apikey": ...}}), which is why every
node with mock credentials failed with "apikey is required", and why the
lane guardrail failed with "Model name was provided but is empty".

Develop carries the same broken check on its default-profile branch, but
harmlessly: when it fails it falls back to using connConfig whole. This
PR made the check the only path in, so failing it lost the config.

Normalize with IJson.toDict() first -- it tests against the correct base
class and is a no-op on already-native values -- then inspect the result.
This also removes the dict(<list>) path that raised
"dictionary update sequence element #0 has length 27".

The unit tests missed all of this because the _FakeIJson stub was a
single flat class that isinstance matched. Split it into _EngineIJson
(the engLib stand-in the engine constructs) and _FakeIJson (the rocketlib
subclass config.py imports) so the trap is reproducible, and add
TestEngineSuppliedConfig. Two of those four fail against the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gbzJyEdgWhSHPgswzw5L

---------

Co-authored-by: Dylan Savage <dylanmsavage@hotmail.com>
erichanwang pushed a commit to erichanwang/rocketride-server that referenced this pull request Sep 7, 2026
…son when profile is set (rocketride-org#2039)

* fix(ai): getNodeConfig drops top-level keys when profile is set

When connConfig has an explicit "profile" key, getNodeConfig only read
connConfig[profile], ignoring every key the caller wrote at the top
level (e.g. an "apikey" alongside "profile" instead of nested under
the profile name). The other branch (no "profile" key) already
overlays top-level keys on top of a nested profile-named block; the
two branches disagreed about where a caller's configuration could
live depending on whether "profile" happened to be set.

Extract that overlay into a shared helper (overlay_top_level) and use
it in both branches, so a key written at the top level is honoured
whether or not "profile" is set, and both branches agree on
precedence: profile defaults < nested profile block < real top-level
values. The "profile" selector key itself is now explicitly excluded
so it can't leak into the resolved config.

Confirmed independently against a real pipeline: an llm_anthropic
node configured with {"profile": "claude-sonnet-4-6", "apikey": "..."}
(flat) silently received no API key, while nesting the same apikey
under connConfig["claude-sonnet-4-6"] worked - reproducing exactly
what's described in rocketride-org#1839 defect 1.

Adds TestExplicitProfileBranchTopLevelFields covering the fix, and
renames the test class that pinned the old (dropped) behavior to
TestExplicitProfileBranchNestedShape since it no longer stands alone.

Fixes rocketride-org#1839

* fix(ai): normalize getNodeConfig's return value with IJson.toDict

Addresses rocketride-org#1839 defect 2: array/object config values arrive from the
engine as IJson, not list/dict, so a node that type-checks its own
declared field (isinstance(value, list)) silently falls through to
its default. IJson.toDict already does the recursive conversion and
is a no-op on values that are already native, so apply it once at the
return boundary rather than pushing the conversion onto every node,
per the issue's own suggested direction.

Adds TestNativeConfigTypes (3 tests) and upgrades the test file's
IJson stub from a shallow dict-only shim to one that actually wraps a
value and recurses, matching the real rocketlib.types.IJson.toDict
closely enough to exercise this. Also adds docstrings to the defect-1
tests to close out the docstring-coverage gap flagged in review.

Full suite: 21/21 passed (up from 18; +3 new).

* docs(ai): add docstrings to remaining test methods in test_config_shapes

Closes out the docstring-coverage check, which scores the whole file:
the pre-existing test methods (written before this PR) had none,
relying on their names alone. Every function in the file is now
documented; no behavior change.

* docs(ai): add class-level docstrings to remaining test classes

Covers the four test classes (TestFlatShape, TestNestedShape,
TestMixedShapePrecedence, TestExplicitProfileBranchNestedShape) that
had per-method docstrings but no class-level summary of which
connConfig shape they exercise. File is now fully documented at both
the class and function level (35/35). No behavior change.

* fix(ai): read the nested profile block via IJson.toDict, not isinstance

rocketlib's IJson subclasses the engLib IJson the engine actually
constructs, and config.py imports the subclass. Every value the engine
produces is a base-class instance, so isinstance(value, IJson) is False
in production.

overlay_top_level() type-tested the nested profile block on that check,
so it took the else branch and used an empty dict -- discarding the whole
block. The node test harness builds exactly that shape
({"profile": "default", "default": {"apikey": ...}}), which is why every
node with mock credentials failed with "apikey is required", and why the
lane guardrail failed with "Model name was provided but is empty".

Develop carries the same broken check on its default-profile branch, but
harmlessly: when it fails it falls back to using connConfig whole. This
PR made the check the only path in, so failing it lost the config.

Normalize with IJson.toDict() first -- it tests against the correct base
class and is a no-op on already-native values -- then inspect the result.
This also removes the dict(<list>) path that raised
"dictionary update sequence element #0 has length 27".

The unit tests missed all of this because the _FakeIJson stub was a
single flat class that isinstance matched. Split it into _EngineIJson
(the engLib stand-in the engine constructs) and _FakeIJson (the rocketlib
subclass config.py imports) so the trap is reproducible, and add
TestEngineSuppliedConfig. Two of those four fail against the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gbzJyEdgWhSHPgswzw5L

---------

Co-authored-by: Dylan Savage <dylanmsavage@hotmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:ai AI/ML modules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(config): getNodeConfig drops top-level keys under a profile, and delivers arrays as IJson

3 participants