Skip to content

feat(delegate): per-task model/provider overrides (+ fix two latent delegation bugs)#35033

Open
banditburai wants to merge 7 commits into
NousResearch:mainfrom
banditburai:fix/delegate-task-model-override
Open

feat(delegate): per-task model/provider overrides (+ fix two latent delegation bugs)#35033
banditburai wants to merge 7 commits into
NousResearch:mainfrom
banditburai:fix/delegate-task-model-override

Conversation

@banditburai

Copy link
Copy Markdown
Contributor

Summary

Adds optional per-task model/provider overrides to delegate_task (each tasks[] entry), with precedence per-task > delegation.* config > parent inherit. Along the way it fixes two latent bugs that silently made delegation.* config inert, so the new feature actually takes effect.

Scope is deliberately tight: the diff touches only tools/delegate_tool.py and tests/tools/test_delegate.py — no top-level params, no run_agent.py dispatch changes. Per-task overrides ride the existing tasks forwarding path.

Problem

Three distinct defects, all in the delegation path:

  • A — silent config swallow. _load_config caught any persistent-load failure with a bare except: return {}, so a malformed config silently dropped all delegation.* settings with zero signal — subagents just inherited the parent and nobody knew why.
  • B — credential pool clobbers a pinned endpoint. A child configured with an explicit delegation.base_url was still handed a credential pool. At runtime _swap_credential (run_agent.py:3164/3182/3186) overwrites self.base_url from the leased entry, silently redirecting the child off its configured endpoint.
  • Feature gap. The build loop fed every child the same batch-wide credential bundle and the tasks[] schema had no model/provider field, so subagents could never run on a different model/provider per task.

Solution

  • A: keep the graceful degrade (still returns {} → parent inherit) but logger.warning(...) first so the failure is observable. This is defense-in-depth — the common corrupt-YAML case already surfaces via _warn_config_parse_failure in hermes_cli/config.py; this catches the residual unexpected-failure path that previously went dark.
  • B: skip the credential pool only for a literal config endpoint. _resolve_delegation_credentials now tags its result with base_url_is_explicitTrue only on the literal delegation.base_url branch, False for a provider-resolved endpoint. _build_child_agent gates the skip on a new override_base_url_explicit flag. A provider-resolved base_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.
  • Feature: new _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-task model/provider onto a dict(cfg) copy and re-resolves through the existing resolver. A per-task provider switch drops base_url/api_key/api_mode from 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 a tool_error rather than leaving half-built children. The tasks.items schema gains model and provider string properties.

Deliberate non-goals: per-task overrides expose model and provider only. base_url and api_key are 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), no delegate_task signature/dispatch change, and _swap_credential is untouched.

Testing

  • tests/tools/test_delegate.py146 passed.
  • tests/agent/test_credential_pool_routing.py tests/run_agent/test_fallback_credential_isolation.py17 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_pool and test_literal_base_url_is_flagged_explicit — (Bug B + guard, both directions); TestPerTaskCredentialResolution (resolver/precedence/endpoint re-derivation); TestPerTaskOverrideReachesChild (un-mocked e2e — a per-task model actually reaches child construction through the build loop, vs the mocked units that isolate the resolver).

Reviewer focus

  • Pool guard: tools/delegate_tool.py:1161 (one-line override_base_url_explicit gate).
  • _resolve_task_credentials: :2489+ (early by-reference return for no-override tasks — safe, consumers are read-only and acp_args is copied at :1043; provider switch drops the endpoint).
  • Pre-pass: :2048 (resolves all per-task creds before any child is built → one bad provider fails the batch cleanly).
  • Flag origin: :2440 (base_url_is_explicit=True only 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/provider only — no top-level delegate_task(model=…) argument — which is what splits Closes from Refs below.

Closes (reported defect fully resolved):

Closes #7833 — Bug B: a child pinned to an explicit delegation.base_url is 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[].model is now in the schema and forwarded end-to-end (tested); it is no longer silently ignored.
Closes #18591 — per-task model with 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_url clobber (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 model path plus the now-visible config-load failure. (Caveat: for a config-only delegation.model on the same provider as the parent with no base_url, see the known limitation under #31155; HERMES_MODEL env-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 (see fb0f579b1 under 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-level model parameter was intentionally removed by the maintainer in fb0f579b1 ("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 remain Refs pending 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 asks model-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/provider is delivered, but per-task base_url/api_key/profile are deliberately not exposed.
Refs #31155known limitation. config-only delegation.model (no base_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_config persistent-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_url pool clobber, our pool-skip resolves it. Downgraded from Closes to Refs because competing PR #26486 (which asserts Fixes #26482) attributes this issue to provider relabeling — children relabeled custom, losing provider identity — fixed in hermes_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):

…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.
@alt-glitch alt-glitch added type/feature New feature or request tool/delegate Subagent delegation P3 Low — cosmetic, nice to have labels May 29, 2026
@alt-glitch

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment