RFC: Node configuration redesign — unify services.json sections, freeze getServiceSchemas #1597
stepmikhaylov
started this conversation in
Ideas
Replies: 2 comments
-
🤖 Internal: Discord sync markerAuto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete. |
Beta Was this translation helpful? Give feedback.
0 replies
-
|
@stepmikhaylov - LGTM ! |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
One node config field is defined in four places today that quietly drift apart. This proposal reformats
services.jsonin place — field definitions and the config structure separated intofields{}+config[], with presets and conditionals folded into a single recursiveenumfield type — and simplifies the parser, whilegetServiceSchemaskeeps emitting the legacy schema so no external component changes.Contents
services.jsonIInstance.nodeConfig1. The problem
The pieces downstream are clean. The pain is concentrated in authoring and in the Python that reads config — and it comes from one thing: the same field is declared, and defaulted, in several unconnected places.
A node's configuration is spread across four sources of truth, and the runtime and the UI don't even read the same one:
fields{}— dotted names, flat global pool, private-over-global mergeshape[]— sections referencing field names by stringpreconfig.profiles[…]Config.getNodeConfig→ Pythonconfig.get('x', DEFAULT)So
fields[].default(what the UI and docs show) andpreconfig.profiles[…](what the node actually receives) are separate declarations that silently diverge, and the node then hardcodes a third fallback. Change a default in one place and the other two lie.Two further structural costs: fields live in a flat global pool resolved by dotted-name magic (
vector.apikey,llm.cloud.apikey) with private-over-global merge, and the same field is duplicated across sections — the filesystem source, for instance, declares a whole parallelPipe.include/Pipe.excludeset beside itsinclude/excludejust to appear in both the Source and Pipe forms.On the Python side, every node opens with the same plumbing — resolve through
Config.getNodeConfig(packages/ai/src/ai/common/config.py), then read each value with a hardcoded fallback. It's called across 138 files; the base class already wraps it behind a property carrying a literal# TODO: this global/object config stuff needs refactoringatrocketlib/filters.py:613.2. Principles & scope
node.json. Eachservices.jsonis refactored in place to the new schema.config[]structure. Section membership becomes a per-entrymodestag, replacing theshapearray.config[]for a one-off field, or in the sharedfields{}pool for a reused one — and referenced (or specialized viainherits) where it is placed. That one declaration produces the resolved runtime value, the form, and the docs row.services.cppis adapted to parse the new format; the publicgetServiceSchemaskeeps emitting today's compiled schema byte-for-byte, so every external consumer — the UI, the overlay product, provider combos — is unaffected. Backward-compat moves from "don't touch the files" to "don't change the output."getNodeConfig. Python reads values through a declarativenodeConfig; the resolver and hardcoded fallbacks go away.3. The new
services.jsonIdentity and registration keep the top-level keys they already have. What changes:
fields{}+shape[]+preconfigcollapse into a reusablefields{}definition map plus an orderedconfig[]structure, and the profile / preset / conditional idiom collapses into anenumfield type.fields{}(definitions) vsconfig[](structure)fields{}— a map (key = name) of reusable field definitions, as today (a local pool, resolved over the shared global pool).config[]— the ordered configuration structure. Each item is either:fields{}(local, then global), or"inherits": "<name>"plus overrides.Resolution unwinds bottom-up, most-specific wins: the
config[]item's own definition (inline /inheritsoverrides), then localfields{}, then globalfields{}.The simplest node —
prompt— today versus target:Before — three blocks:
After — one structure entry, defined inline (a one-off field needs no
fields{}entry):Ordered array, position is order
config[]is an array; array position is the display order — the ordering thatshape[].propertiesused to encode implicitly. A field reused across nodes is declared once infields{}and pulled in by name.Sections become
modesA
config[]entry'smodeslists the sections it appears in:"modes": "Source"or"modes": ["Target", "Export"]. Omit it and the entry applies to every mode. This both shares a field across sections (one entry, several modes) and specializes it where sections genuinely differ (two entries, samename, disjoint modes) — which is how the old cross-section duplication is resolved:Before — parallel field sets + shape:
After — one definition, placed with tags:
Full structure (an LLM node with presets)
The model selector is an
enumfield: each value is a model, itstitleis the dropdown label, and itsconfigpins that model's metadata (and, forcustom, reveals the editable fields). No separatepresetsblock.4. Field model, modes & types
Every field definition is
{ type, … }; everyconfig[]placement is a string ref, an inline field ({ name, type, … }), or aninheritsextension ({ inherits, …overrides }), optionally carryingmodes. The type vocabulary folds today'stype+format+secure+ui.widget+enumsprawl into a small closed set.The field envelope
name— the runtime key (was the object key / last dotted segment).modes— section membership; string or array; omit = all. Replacesshape/section.type,label,help,default,required,min/max,readonly,hidden,advanced.The
enumtypeenumcarries avaluesmap — each key is a stored value, each entry a{ title, config? }:title— the option label (replacesenumNamesand the*>preconfig.profiles.*.titleprojection).config— optional nestedconfig[]revealed when the value is selected, stored under a subtree keyed by the value. Omit it for a plain select; use it for a preset bundle / conditional reveal / per-profile subtree — they are all the same construct.fieldsobject +shape[]fields{}defs +config[](ref / inline /inherits) + per-entrymodestype:"string"/ +format:"textarea"string/texttype:"number"/"integer"number/intmin/maxtype:"boolean"boolsecure:true+ ApiKey/password widgetsecretformat:"url"/"data-url"url/filetype:"array"items:string / objectstring[]·row[]of agrouptype:"object"+propertiesgroupwith a nestedconfig[]enum:[[v,label]]+enumNamesenum+values:{ value:{ title } }combo:"llm"type:"provider",of:"llm"conditional:[{value,properties}]enumvalue's nestedconfig[]optional:falserequired:trueobject:"<profile>"+preconfigenumfield with per-valueconfigvector.apikey)fields{}def + string ref, resolved local → globalui.chipConditionals become a reveal. Today a
conditionalcompiles to JSON-Schemadependencies → oneOf— the source of much nesting. In the new model a value simply lists the fields it reveals in its ownconfig[], and a nested selector nests its ownconfig, so chained conditions fall out of the recursion. The parser still expands this to the legacyoneOfon output (§6); the runtime uses it so a hidden field never leaks a value. (A lightweight boolean show-if — aboolthat reveals fields without being a fullenum— is UI-only and deferred; see §12.)5. Runtime:
IInstance.nodeConfigA typed, resolved view of the node's config, populated from the declaration. Declaring a field is all it takes to read it — no resolver call, no fallback literal. Scope: the Python pipe nodes (the Source/Target endpoints are C++-native).
Before:
After:
Added to the rocketlib base classes (
rocketlib/filters.py) onIInstanceBaseandIGlobalBase(~93 of today's call sites live inIGlobal.py). The value is resolved by overlaying four layers, highest priority last:defaultservices.json· field definition (fields{}/ inline)configservices.json· the chosenenumvalue'sconfig(defaults + revealed fields).pipe·connConfigcurrentObject.parameters[pipeType.id]non-null wins · nested groups merge recursively · cached per object, cleared in
close()This preserves the exact layering
getNodeConfig+ theparamsproperty implement today, including per-object overrides and the flat / nested-under-profileconnConfigshapes in saved pipelines. Field declarations are read from the sameservices.jsonthe engine parses (viagetServiceDefinition, now returning the new-format raw definition).Cross-provider resolution. ~11 nodes (
autopipe,vectorizer,chroma,qdrant,pinecone, …) resolve another node's config when nested as a sub-provider (getMultiProviderConfig). A module helperresolveConfig(logicalType, connConfig)— the declaration-driven successor togetNodeConfig— handles that;Config.getNodeConfigremains a thin deprecated shim over it until those sites migrate.6. Engine: input new, output legacy
This is the load-bearing idea (from rev 2).
services.cpplearns the new format on the way in and keeps emitting the current compiled schema on the way out — so the simplification is internal and invisible to everything downstream.flowchart LR A["services.json (new)<br/>fields{} + config[] + enum values"] --> B["services.cpp (simplified)<br/>resolve config[] refs/inherits<br/>group by modes<br/>enum values → selector + oneOf + per-value subtree/defaults"] B --> C["getServiceSchemas (legacy · frozen)<br/>serviceSchema[section] = {schema, ui}"] C --> D["UI (RJSF)"] C --> E["Overlay product"] C --> F["Provider combos (processCombo)"]What the parser sheds (the OSS-simplification win): the flat global-field pool's dotted-name resolution magic, the
shape-to-fieldsindirection, and the per-section field duplication. What it keeps is a single compile step from the cleanconfig[]to the legacy per-section{schema, ui}: resolving refs/inheritsagainstfields{}, grouping bymodes, and materializing eachenumvalue'sconfiginto theprofile-selector enum + theoneOfreveal + injected defaults the current schema carries. Complexity moves from the input model to a single, testable emitter.7. UI & external components
Nothing in
shared-ui/…/NodeConfigPanel.tsx, therrext_servicestransport, the TS/Python client types, or the RJSF widget layer changes. The UI still readsservice.Pipe.{schema, ui}; edited values still persist 1:1 into the.pipeconfigobject; every saved pipeline still loads. That is the entire point of freezing the output.8. The compatibility boundary
The guarantee is relocated: not "these files are off-limits" but "this output never changes."
getServiceSchemasoutput (WS 5565)modes), but compile to the same schema. Endpoints stay C++-native..pipeconfigshapeconfig.<value>.<key>layout; resolver keeps the flat and nested-under-profile read paths.ConfigureService.cpp(mode → section)source/target/export/transformmode.tools/sync_models/…/merger.py)modelenum'svaluesinstead ofpreconfig.profiles— same data, new location.getServiceDefinition(raw, Python-facing)9. Decisions taken
services.json, new schema, all sections;services.cppadapted and simplified;getServiceSchemasunchanged.fields{}holds reusable field definitions;config[]is the ordered structure that references (string), inlines, orinherits-and-overrides them. Resolution unwinds bottom-up:config[]item → localfields{}→ globalfields{}. The shared global pool is kept (this settles rev 2's §12 "shared fields" question).enumfield, no separate block. Theprofile+conditional+object+preconfigidiom collapses into oneenumfield type whosevalueseach carry atitleand an optional nestedconfig— which is simultaneously the preset bundle, the selector, the conditional reveal, and the per-profile subtree. LLM nodes keep dozens of model values maintained by the sync tool.getServiceSchemasoutput, andservices.cppcarries one format — not two — as soon as the sweep completes.10. Implementation plan
Six phases. The parity harness in Phase 0 is the spine — every later phase is gated on identical
getServiceSchemasoutput and identical resolved config.Phase 0 — Spec, converter & parity harness
Make correctness mechanically checkable before touching the engine.
fields{},config[], theenum/valuesconstruct,inheritsresolution).services.json→ new format.getServiceSchemasoutput for all 145 files; build a harness asserting new-format output is byte-identical, and that resolved config matches for real.pipefixtures.Phase 1 — Parser: read new, emit legacy ·
C++Teach
services.cppthe new format while freezing its output.config[]refs/inheritsagainstfields{}; group by mode; materializeenumvalues → selector +oneOf+ per-value subtree/defaults.getServiceSchemas; parser LoC down.Phase 2 — Convert the tree ·
C++ · JSONMove all 145 files to the new format.
modessplits and enum-value materialization.logany file the converter can't handle for manual follow-up — no silent skips.Phase 3 — Runtime:
nodeConfig·PythonKill
getNodeConfigacross 138 files.resolveConfig(), andIInstance/IGlobal.nodeConfigin rocketlib.Config.getNodeConfigto a shim; convert node code toself.nodeConfig.*.nodeConfig; node tests green; shim only behind provider-nesting sites.Phase 4 — Docs generator
Keep the co-located schema tables truthful.
nodes/scripts/gen-node-tables.mjsat the newfields{}+config[].builder docs:buildper the co-located-docs rule.Phase 5 — Final simplification
Remove the last of the legacy machinery.
preconfig/ dotted-pool code paths.Config.getNodeConfigonce provider-nesting sites are onresolveConfig.services.cpptest + client types.11. Risks & mitigations
getServiceSchemasbreaks the overlay silently. Mitigation: the byte-identical harness (Phase 0) is a hard gate on every phase; VERSION only bumps if we deliberately change the surface.services.jsoninto this engine, dropping legacy input parsing breaks it. Mitigation: confirm before Phase 1 (§12); if so, keep a thin legacy reader instead of removing it in Phase 5.modessplits and enum-value bundles explicitly; every file is output-diffed, and unconvertible cases are logged, not skipped.getServiceDefinitionreaders. A handful of nodes read rawshape/preconfigviagetServiceDefinition. Mitigation: inventory them in Phase 0; update in step with the format (they're all in-repo).12. Open questions
getServiceSchemasoutput, or does it also ship raw legacyservices.jsonfiles this engine must parse? This decides whether Phase 5 removes legacy input parsing entirely.boolthat shows/hides fields without being a fullenum— model it as a degenerateenum(values:{true:{config},false:{config}}) or add a smallwhenback for that case only? Deferred; UI-only, no effect on code-side config access.config[]entries sharing anamewith disjointmodes(proposed) — or per-mode overrides inside one entry (e.g."overrides": { "Pipe": { "help": "…" } })?autopipe,vectorizer): model the nested{provider, <provider>:{…}}as a first-classproviderfield type now, or keep them on the shim longest?Grounded in:
packages/ai/src/ai/common/config.py·rocketlib/filters.py·engLib/store/services/services.cpp·engLib/store/python/bindings.cpp·shared-ui/…/NodeConfigPanel.tsx·nodes/scripts/gen-node-tables.mjs. Corpus: 145 config files · sections Pipe 132 / Transform 11 / Source 2 / Target 2 (+ nested parameters, estimation) · 138 getNodeConfig sites.Beta Was this translation helpful? Give feedback.
All reactions