feat(delegate): per-task model/provider overrides (+ fix two latent delegation bugs)#35033
Open
banditburai wants to merge 7 commits into
Open
feat(delegate): per-task model/provider overrides (+ fix two latent delegation bugs)#35033banditburai wants to merge 7 commits into
banditburai wants to merge 7 commits into
Conversation
…nfig
_load_config swallowed any exception from the persistent load_config()
path and returned {}, making all delegation.* settings inert with no
signal. Log a WARNING before degrading to inheritance. Refs NousResearch#32671 NousResearch#12440.
When delegation.base_url pins a child to a direct endpoint, _build_child_agent attached a credential pool whose lease/swap (_swap_credential) overwrote the configured base_url at run time, silently redirecting the child. Skip pool attachment when override_base_url is set. Refs NousResearch#7833 NousResearch#20558.
Add optional model/provider to each tasks[] entry. Resolved per task via _resolve_delegation_credentials (precedence: per-task > delegation.* config > parent inherit); a per-task provider re-derives its own endpoint. base_url and api_key are intentionally not exposed per-task. Rides existing tasks forwarding so no dispatch/shim change. Refs NousResearch#5012 NousResearch#6306 NousResearch#10995 NousResearch#14974.
Pythonic-conciseness pass over the delegate_task override work: - pool-skip guard as a ternary; endpoint-key drop as a loop - why-only comments throughout (drop what-comments) - hoist test imports to module scope - tests assert behavior, not internals: no-override path now asserts the resolver is not called (the real contract) rather than object identity alone; trimmed an over-specified _run_single_child fixture.
The pool-skip guard keyed on override_base_url truthiness, but _resolve_delegation_credentials populates base_url for ANY resolved delegation.provider (openrouter/nous/...), not just a literal delegation.base_url. So the guard disabled multi-key credential-pool rotation for every provider-delegated subagent — a silent resilience regression, since a provider pool's entries carry that same provider's endpoint and a swap is legitimate key rotation, not an off-endpoint redirect. Tag the literal-base_url branch with base_url_is_explicit=True and gate the skip on that flag instead. The NousResearch#7833/NousResearch#20558 fix (don't clobber a configured direct endpoint) is preserved exactly; provider-delegated children keep their rotating pool. Add regression tests for both directions.
The existing credential-error test mocks _resolve_delegation_credentials globally, so it only exercises the batch-level resolution failure and never reaches the per-task pre-pass. Add a test asserting that an unresolvable per-task provider returns a clean tool_error and builds NO children (the all-or-nothing contract), while a valid sibling task is not spawned.
Collaborator
|
Duplicate of tracking issue #14974. This is the ~11th PR implementing per-task model/provider overrides. See also open PRs #25530, #23649, #17756 and many more. This one additionally fixes two latent bugs (silent config swallow, credential pool clobbers pinned endpoint) which add value beyond the prior PRs. |
This was referenced May 30, 2026
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.
Summary
Adds optional per-task
model/provideroverrides todelegate_task(eachtasks[]entry), with precedence per-task >delegation.*config > parent inherit. Along the way it fixes two latent bugs that silently madedelegation.*config inert, so the new feature actually takes effect.Scope is deliberately tight: the diff touches only
tools/delegate_tool.pyandtests/tools/test_delegate.py— no top-level params, norun_agent.pydispatch changes. Per-task overrides ride the existingtasksforwarding path.Problem
Three distinct defects, all in the delegation path:
_load_configcaught any persistent-load failure with a bareexcept: return {}, so a malformed config silently dropped alldelegation.*settings with zero signal — subagents just inherited the parent and nobody knew why.delegation.base_urlwas still handed a credential pool. At runtime_swap_credential(run_agent.py:3164/3182/3186) overwritesself.base_urlfrom the leased entry, silently redirecting the child off its configured endpoint.tasks[]schema had nomodel/providerfield, so subagents could never run on a different model/provider per task.Solution
{}→ parent inherit) butlogger.warning(...)first so the failure is observable. This is defense-in-depth — the common corrupt-YAML case already surfaces via_warn_config_parse_failureinhermes_cli/config.py; this catches the residual unexpected-failure path that previously went dark._resolve_delegation_credentialsnow tags its result withbase_url_is_explicit—Trueonly on the literaldelegation.base_urlbranch,Falsefor a provider-resolved endpoint._build_child_agentgates the skip on a newoverride_base_url_explicitflag. A provider-resolvedbase_url(e.g.delegation.provider: openrouter) keeps its pool, because that pool's entries carry the same provider's endpoint — a swap there is legitimate same-provider key rotation, not an off-endpoint redirect._resolve_task_credentials(task, cfg, batch_creds, parent_agent)returns the batch bundle unchanged when a task carries no override; otherwise it overlays the per-taskmodel/provideronto adict(cfg)copy and re-resolves through the existing resolver. A per-task provider switch dropsbase_url/api_key/api_modefrom the copy so the new provider re-derives its own endpoint. Resolution runs in an up-front pre-pass (before any child is built), so an invalid per-task provider fails the whole call cleanly with atool_errorrather than leaving half-built children. Thetasks.itemsschema gainsmodelandproviderstring properties.Deliberate non-goals: per-task overrides expose
modelandprovideronly.base_urlandapi_keyare intentionally not per-task — an LLM-emitted tool call must not be able to route a subagent to an arbitrary endpoint or inject a key. There is no batch-wide override arg (a shared value is just the same field on each task), nodelegate_tasksignature/dispatch change, and_swap_credentialis untouched.Testing
tests/tools/test_delegate.py→ 146 passed.tests/agent/test_credential_pool_routing.py tests/run_agent/test_fallback_credential_isolation.py→ 17 passed (confirms the guard-narrowing didn't regress pool/isolation behavior).Coverage:
TestLoadConfig(Bug A, warn + degrade);TestExplicitEndpointSkipsPool— incl.test_provider_resolved_base_url_still_attaches_poolandtest_literal_base_url_is_flagged_explicit— (Bug B + guard, both directions);TestPerTaskCredentialResolution(resolver/precedence/endpoint re-derivation);TestPerTaskOverrideReachesChild(un-mocked e2e — a per-taskmodelactually reaches child construction through the build loop, vs the mocked units that isolate the resolver).Reviewer focus
tools/delegate_tool.py:1161(one-lineoverride_base_url_explicitgate)._resolve_task_credentials::2489+ (early by-reference return for no-override tasks — safe, consumers are read-only andacp_argsis copied at:1043; provider switch drops the endpoint).:2048(resolves all per-task creds before any child is built → one bad provider fails the batch cleanly).:2440(base_url_is_explicit=Trueonly on the literal config base_url branch).Issues & related PRs
Reconciled item-by-item against the full delegation cluster (18 active issues + 2 active PRs, plus stale/closed context). This PR ships per-task
tasks[].model/provideronly — no top-leveldelegate_task(model=…)argument — which is what splitsClosesfromRefsbelow.Closes (reported defect fully resolved):
Closes #7833 — Bug B: a child pinned to an explicit
delegation.base_urlis no longer rebound to the parent's pool via_swap_credential; the pool is skipped for a literal config endpoint.Closes #17685 — per-task
tasks[].modelis now in the schema and forwarded end-to-end (tested); it is no longer silently ignored.Closes #18591 — per-task
modelwith the exact fallback chain it requests (per-task >delegation.model> parent); this issue asks for no top-level arg.Closes #27294, Closes #27295, Closes #27296 — same explicit-
delegation.base_urlclobber (filed three times); fixed by the pool-skip.Closes #12440 — the blocking impact (sub-agents stuck on a small-context default) is resolved via the working, tested per-task
modelpath plus the now-visible config-load failure. (Caveat: for a config-onlydelegation.modelon the same provider as the parent with nobase_url, see the known limitation under #31155;HERMES_MODELenv-override is out of scope.)Closes #15789 — its primary ask is per-task
tasks[].model/provider(the detailed schema it proposes); that surface is delivered and tested. The issue mentions a top-level single-task parameter only in passing, and that surface was intentionally removed upstream (seefb0f579b1under Refs), so the substantive request is fully met.Refs (related but not fully resolved here):
Refs #5012, #6306, #10995, #17732, #23467, #32711 — each of these requests a top-level / per-call
delegate_task(model=…)argument. This PR deliberately does not add it: the top-levelmodelparameter was intentionally removed by the maintainer infb0f579b1("refactor: remove model parameter from delegate_task function … simplifies the function signature and enforces consistent behavior across task delegation"), which stripped it from the signature, schema, and handler together. Re-introducing it would revert that decision, so these remainRefspending a maintainer call on whether to reverse the refactor. The per-task (tasks[]) surface this PR adds is a different, nested surface that does not conflict with that removal, and it satisfies the underlying routing need (a model string per child) for all of these. (#6306 asksmodel-only across three levels; #23467/#32711 are explicitly about the removed top-level parameter.)Refs #14974 — tracking issue for per-task endpoint overrides; per-task
model/provideris delivered, but per-taskbase_url/api_key/profile are deliberately not exposed.Refs #31155 — known limitation. config-only
delegation.model(nobase_url) where parent and child share the same provider: our guard intentionally keeps the pool for a provider-resolved endpoint (to preserve key rotation), so the child can still inherit the parent's pooled model. The root fix is endpoint/model-identity keying in_resolve_child_credential_pool, which this PR does not change.Refs #32671 — Bug A: a silent
_load_configpersistent-load failure is now observable (warns instead of swallowing), but config-read semantics are otherwise unchanged — surfaced, not fully closed.Refs #26482 — if the reported symptom is the explicit-
delegation.base_urlpool clobber, our pool-skip resolves it. Downgraded fromClosestoRefsbecause competing PR #26486 (which assertsFixes #26482) attributes this issue to provider relabeling — children relabeledcustom, losing provider identity — fixed inhermes_cli/gateway.py, which this PR does not touch. Defer to the maintainer on which mechanism #26482 actually reports.Related / competing PRs (overlap — not cleanly superseded; recommend maintainer consolidation):
modelparam + per-task. The top-level half revertsfb0f579b1(the maintainer's deliberate removal of that param); it does not fix Bug A or Bug B. Its claim thatdelegation.modelwas already plumbed matches our Bug A finding.modelparam, passes it through the handler lambda, and overridescreds['model']after resolution (a post-resolution patch, vs. our up-front single-resolver pre-pass), plus per-task model/provider. Also revertsfb0f579b1, and likewise fixes neither Bug A nor Bug B. Its premise ("schema advertises amodelthe function ignores") is stale —fb0f579b1removed the schema property too, so no schema/impl mismatch exists today.Fixes #26482via the gateway relabel path this PR does not touch).