feat: per-role model binding — tiers, operator maps, --role overrides, pack-adopted collaborations - #287
Merged
Merged
Conversation
saucam
force-pushed
the
feat/role-model-binding
branch
from
August 6, 2026 05:52
fc84970 to
0475d02
Compare
Collaborator
Author
|
Auto-maintained by pr-shepherd (iteration 1):
No content changes; commits are identical apart from the new base. CI is re-running on the rebased head. |
Collaborator
Author
|
pr-shepherd: CI is green on
The known |
akashjavelin
approved these changes
Aug 6, 2026
…k show --resolve
Slices 1–2 of docs/role-model-binding.md (brought onto this branch from
docs/role-model-binding and amended): a model binding is policy, not
methodology — the pack declares what a role needs (a semantic tier), the
operator's config maps tiers to concrete models, and the invocation can
override per run. One pure resolution chain shared by every surface.
- src/daemon/pipeline/binding.ts (new): resolveBinding() — the six-rung
precedence chain (cli > config-role > phase-pin > role-pin > config-tier
> default) with resolvedFrom naming the winning rung; plus
roleBindingsFromSpecs() compiling collab-grammar --role specs (rejecting
*count — pipelines run one session per phase).
- pack.ts: roles gain optional tier (non-empty, ≤64 chars). The role-level
provider/model pins (collab fleet path) join the chain as rung 4 — a doc
amendment: the design predates their existence.
- config.ts: pipeline.modelTiers / pipeline.modelRoles operator maps.
- manager.ts: CreatePipelineOpts.roleBindings + modelConfig; create()
resolves once per phase and persists {provider, model, resolvedFrom} on
CLONED phase defs (a pack's pipeline array is shared across runs), so
resume/retry replay the persisted binding — deterministic under config
change. Unknown binding role fails create listing the declared roles.
- wire: pipeline.create gains roleBindings (validated against the provider
registry in the handler); PipelinePhaseWire carries provider/model/
resolvedFrom; PackWire carries resolvedRoles + phasePins.
- CLI: pipeline run --role (repeatable, reuses parseRoleSpec); pipeline
status renders ⟨provider:model ← rung⟩; pack show --resolve is the
pre-flight view — per-role effective bindings with phase pins listed
separately so stale pins stay visible.
- runner.ts: a failed phase turn names its binding + rung, so a bad model
id points at the layer to fix.
Tests: precedence table per rung, create-time persistence + config-change
survival, validation errors, tier schema bounds, resolve-view shape, wire
round-trip, formatter rendering. Full suite: 2242 pass / 1 pre-existing
flake (ORCHESTRATOR_FLEET_TOOLS — fails identically on origin/main).
Slice 3 (pack-adopted collaborations, single-session --model) follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…option UI + build stamp Slice 3 of docs/role-model-binding.md (§6 + §6.2), on top of slices 1-2's resolution chain (binding.ts). A. Collab/pack exclusivity lifted (daemon). session.create with both collaboration + pack now ADOPTS the pack: adoptPackRoles() (collaboration.ts) binds the roster strictly to the pack's declared roles (unbound name errors listing them), takes write authority from the role YAML (a conflicting spec is an error, not an override), defaults purpose to the role summary, and resolves each role's model through the §6.1 chain (cli spec model > modelRoles > role-YAML pin > modelTiers[tier] > backend default) via the shared resolveBinding(). Recorded clarification: the roster's provider is authoritative — a winning rung on a different backend is skipped with a warning rather than silently moving the role (a model id doesn't transfer across vendors, and collab's standing rule is no silent backend collapse). roleChildPosture passes the role YAML's real write/network/envelope/ exceptions to the canUseTool fence; compileGoalPack composes pack ETHOS → goal → roster under the real pack id (children get ETHOS → goal, no roster — independence); the orchestrator gets the pack's subagents like a --pack session. packRole with collaboration is rejected (category error). *count fan-out stays valid. Free-form collabs are byte-identical to before. B. --model on single sessions (§6.2). session.create gains `model` (wire + CLI + terminal client): validated provider-aware at create (Claude alias can't ride onto another vendor; past that the backend validates), placed as the Session's defaultModel — the same slot the collab fleet path already uses. With --pack --pack-role and no --model, the chain minus phase-pin/cli rungs resolves it; the winning rung's provider fails closed if unregistered and yields to an explicit conflicting --provider with a warning. C. Web collab dialog: the pack select now serves collaborate mode (\"\" = free-form). Choosing a pack turns worker role names into a strict <select> over the pack's declared roles, hides the write toggle (the role YAML's call), shows Slice 2's resolvedRoles as effective-model placeholders, and sends the pack on the wire. Rows stay in the <Index> — the swap is a signal-driven element change in the same slot, no remount, focus preserved. D. Build stamp: vite bakes the short git commit in (define __BUILD_COMMIT__), surfaced in the help modal footer/title — served-dist drift is now diagnosable at a glance. Tests: adoptPackRoles precedence per rung + cross-backend skip + write conflict, posture/constitution composition units, end-to-end adoption create (real pack dir; profiles, models, write flags on children), §6.2 model resolution (explicit/pin/tier rungs, wrong-vendor rejection, collab rejection), web adoption UI (strict select, placeholders, payload, free-form restore). Full daemon suite: 2264 pass / 1 pre-existing flake (ORCHESTRATOR_FLEET_TOOLS — identical on the pre-slice baseline). Web: 395 pass, typecheck + lint clean, dist rebuilt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s 1-2 Closes the merged punch list from the two review rounds (bench + adversarial) on the per-role model binding feature (docs/role-model-binding.md). The headline adversary finding: pipeline-surface bindings were resolved, persisted, and DISPLAYED but never used — runPhaseOnSession deliberately ignored provider/model, so `pipeline run --role` was inert. - Bindings now APPLY on the pipeline surface. runPhaseOnSession sets the bound session's model for the phase turn via Session.overrideModel — a non-persisting, provider-aware override — and restores the previous model in a finally (reliable on phase failure; a user's mid-phase set_model wins over the restore; a mid-phase daemon crash resumes on the pre-override model and the re-driven phase re-applies it). The run session's provider is authoritative (same rule as collab adoption): a resolved binding naming a different backend is skipped at CREATE with a warning naming the rung and is never persisted as effective — status/CLI never render a binding that won't apply, and the runner's failure message only names bindings actually in effect. - Model-only rungs no longer defeat the cross-vendor guard: adoptPackRoles, the §6.2 single-session chain, and pipeline #bindModels validate chain-resolved models provider-aware (resolveModelIdForProvider) and skip with a warning — never a hard-fail for a model the operator never typed, never a silent vendor transfer. Only explicitly typed models (--role name:provider:model, --model) may hard-fail create validation. - Create replies carry the collected warnings (warnings on response.ok / pipeline.snapshot); the CLI prints them and the web renders them (modal warning block with explicit dismiss; PipelineRunner warn strip). - Web modal open-order bug: a leftover pipeline-mode packId's transition to "" wiped the freshly-seeded collab role names on reopen (open pipeline modal → Esc → open collaborate → blank names, Create disabled). The clearing is now gated on the →pack direction. The effective-binding placeholder also respects the row's chosen backend — it no longer advertises a model the daemon would skip. - Resume restores a collab child's resolved model: #resumeRoleChild threads the roster model (same source as spawn — plannedChildFor) into the child's options, and the resume constructor no longer clobbers it with the conductor-only default. - Wire bounds: phaseDefSchema provider/model gain min(1) (an empty string persisted as a phantom phase-pin); config ModelBindingSchema reuses LIMITS.MODEL_MAX instead of a second literal 256. - One case rule everywhere: role-name matching is case-insensitive on the pipeline surface too (#bindModels lookup + declared-role check, roleBindingsFromSpecs duplicate detection), matching adoptPackRoles and the collab validator. Doc §5 states it; the cli.ts comment no longer claims the wire path rejects *count (the wire shape has no count field). - Doc honesty: §4's "actual token usage per phase" claim moved to §7 non-goals (not implemented); §3 now states the provider-authoritative + apply/restore semantics precisely; header status reconciled. Tests: +11 daemon, +3 web. New coverage: the phase turn actually runs with the bound model (asserted on the model handed to the provider stub) and the session's model is restored after; cross-provider bindings are skipped at create with the warning on the snapshot; a skipped binding strips the phase's own pin fields; model-only pins validate per-backend on both the pipeline and adoption paths (and sail through validateCollaboration instead of hard-failing); a model-bound collab child resumes WITH its model; session.create surfaces tier warnings on the reply; case-insensitive matching on both surfaces; empty-string phase pins rejected at the schema. Full daemon suite: 2275 pass / 1 pre-existing flake (ORCHESTRATOR_FLEET_TOOLS — identical on the pre-fix baseline). Web: 398 pass, typecheck + lint clean, dist rebuilt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator
Author
|
Rebased onto main after #286 merged — the only conflict was |
saucam
force-pushed
the
feat/role-model-binding
branch
from
August 6, 2026 06:08
0475d02 to
16e31ff
Compare
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.
Implements
docs/role-model-binding.md— design PR #286. Note: this branch carries the amended copy of the doc (updated during implementation + review: role-pin rung, provider-authoritative rules, §4 honesty fix), superseding #286's version. Suggest closing #286 in favor of this PR — or merge it first and let this branch's copy win.What this is
A model binding is policy, not methodology. The pack declares what a role is (envelope) and what it needs (
tier:— a semantic class likereasoning-max); which concrete model satisfies that need is an operator decision, per machine, per run. One six-rung resolution chain shared by every surface: CLI--role> configmodelRoles> pack phase pin > role-YAML pin > configmodelTiers> provider default — withresolvedFromnaming the winning rung everywhere it surfaces.The three slices
resolveBinding()(pure, one function, every caller),pipeline.modelTiers/modelRolesconfig maps,pipeline run --role(collab grammar,*countrejected), per-phase bindings resolved once at create and persisted (deterministic resume under config change),pipeline statusrendersprovider:model ← rung.pack show --resolve: the pre-flight view of each role's currently-effective binding under this machine's config, with phase-level pins listed separately so stale pins stay visible.--collaborate --packlets the pack define what each role IS (strict name binding, real envelopes from role YAML, ETHOS→goal→roster constitution composition), models via the same chain minus the phase-pin rung;--modelon single sessions with provider-aware validation and the §6.2 chain for--pack-role.Review process
Two review rounds (bench + adversarial) ran against the branch before this closeout; the third commit resolves their merged punch list.
Headline adversary finding: bindings were display-only on the pipeline surface.
runPhaseOnSessiondeliberately droppedprovider/model, sopipeline run --role adversary:claude:claude-fable-5resolved, persisted, and displayed the binding — and then ran the phase on the session's default model anyway. Fixed by applying the bound model to the run session for each phase turn (Session.overrideModel— non-persisting, provider-aware, restore-in-finally; a user's mid-phaseset_modelwins over the restore) and adopting the collab-adoption rule at create: the run session's provider is authoritative — a resolved binding naming another backend is skipped with a warning naming the rung and never persisted as effective.Other round findings fixed here: model-only pins no longer defeat the cross-vendor guard (validated per-backend on all three paths; skip-with-warning, never a hard-fail for a model the operator never typed); binding warnings now reach the creating client (
warningsonresponse.ok/pipeline.snapshot, rendered in CLI + web) instead of dying in the daemon console; the web modal's open-order bug (pipeline open → Esc → collaborate open wiped the default role names and bricked Create); collab children resume WITH their resolved model; empty-string phase pins rejected at the schema (min(1)); one case-insensitivity rule for role names on both surfaces; doc de-overclaimed (per-phase token attribution moved to non-goals).Tests
ORCHESTRATOR_FLEET_TOOLS › the BUILT server registers exactly those toolsincollaboration.test.tsfails under full-suite load (passes standalone, 138/138; identical behavior on clean main). The single failure in the full run is exactly this test.bun run typecheck(root + protocol + core + web) andbun run lintclean.Web build stamp / stale dist
Slice 3 added a build stamp to the web UI (vite
define, shown in the Help modal) after a review round was nearly derailed by a staleweb/dist— the daemon servesdist/live per-request, so an un-rebuilt dist silently demos old code while the source reads fixed.web/distis rebuilt as part of this branch's verification (it is gitignored; deployers runbun run build:web).🤖 Generated with Claude Code