fix(tool-delegate): thread model_role/provider_preferences through the resume path - #350
Conversation
…e resume path A delegation pinned to a model lost that pin the moment it was resumed. execute() resolves model_role -> provider_preferences before routing to either branch, then the resume branch passed neither on to _resume_existing_session, which in turn had no parameters to receive them and called the app layer's session.resume capability with (sub_session_id, instruction) only. The app-layer half already accepts both kwargs (amplifier-app-cli #292); this repo never sent them, so the resumed leg fell back to settings priority -- silently, and invisibly in telemetry, since delegate:agent_resumed carried no routing field to compare against delegate:agent_spawned. - thread provider_preferences + raw_model_role from the call site - record each spawn's resolved routing in _session_routing, so a resume issued as (session_id, instruction) -- the shape real callers use -- reuses the role pinned at spawn. Caller-explicit routing still wins. - send only the kwargs the app-layer capability declares; warn by name when holding any back, so an older app layer degrades loudly instead of crashing or dropping silently - carry model_role/provider_preferences on delegate:agent_resumed, matching delegate:agent_spawned Test fails on parent 0d7b3f6 (8 failed, 2 passed) and passes on this commit (10 passed). Full suite: 1873 passed, 1 skipped.
Manager verification (independent, scratch clone)Verified in a fresh clone of Full suite — (the one warning is the pre-existing Fail-before reproduced — new test file kept, module source reverted to the …the same 8 named in the PR body. Restoring the module: The 2 that pass before are the right 2. They are the
The one behavioural change for a caller that passes nothing is the intended Diff scope — Nothing outside the tool-delegate resume path, its tests/README, and the lane CI: green on this exact head (6 test jobs, ubuntu+windows × 3.11/3.12/3.13, plus CLA). Merge policy: bug/correctness fix with a reproduced fail-before test → merging |
The defect
A delegation pinned to a model lost that pin the moment it was resumed.
Confirmed on the wire (rc0). Two independent sites had to be fixed; the
app-cli half shipped in microsoft/amplifier-app-cli#292 (
31ad917). This PRis the amplifier-foundation half, which was the one still open.
The two sites (at parent
0d7b3f6)Both in
modules/tool-delegate/amplifier_module_tool_delegate/__init__.py.Call site,
execute():1533-1539 —execute()has already resolvedthe caller's
model_roleintoprovider_preferences(resolver block at:1396-1490); both are live locals. The spawn branch 60 lines below passes
both to
_spawn_new_session. This branch passes neither:Resume path,
_resume_existing_session():2113-2121 — no parameterexists to receive them even if the call site had sent them. And the wire
call to the app layer, :2199-2202:
session.resumeis the app-layer capability — this call is the requesttool-delegate makes of the app layer, and its kwargs are what the app layer
turns into the resumed session's provider config. The app layer already
accepts
provider_preferencesandmodel_role(app-clisession_spawner.py:911-926 →
resume_sub_session:1246-1251, #292). This repo simply neversent them, so the promotion had nothing to promote and the resumed leg fell
back to settings priority.
It failed silently:
delegate:agent_spawnedcarriedmodel_role,delegate:agent_resumedcarried no routing field at all — nothing to compareit against.
The fix
provider_preferences+raw_model_role._resume_existing_sessionaccepts both and sends them toresume_fn._session_routingrecords the routing each spawn resolved to, keyedby sub_session_id (mirrors the existing
_session_agentscache). This iswhat makes the acceptance criterion hold for the shape real callers use:
routing is pinned once on the spawn call, and resumes are then issued as
(session_id, instruction)with no routing argument. Caller-explicitrouting on the resume call still wins.
_supported_resume_routing_kwargs()introspects the app-layercapability and sends only what it declares. An app layer still taking
(sub_session_id, instruction)keeps working instead of dying on anunexpected kwarg — but cannot drop the routing quietly: withheld kwargs
are named in a
logger.warning.delegate:agent_resumednow carriesmodel_role/provider_preferences, matchingdelegate:agent_spawned. Additive;absence in an old capture means UNKNOWN, never "no routing".
Evidence
Fail-before / pass-after — same test file both runs; the module fix was
stashed for the "before" run, so only the module differed.
Before (parent
0d7b3f6, fix stashed):The 2 that pass before are the backward-compatibility tests — they should
pass before; they pin what the fix must not break.
After:
10 passed.Full suite (
uv run pytest -q, localtestpaths=tests+modules/tool-delegate/tests):The one warning is pre-existing (
tests/test_subprocess_runner.py).Honest scope of "the wire"
This repo does not build LLM requests — it defines the capability contract;
the app layer implements it. The strongest assertion available here is at
the
session.resumeseam, which is exactly where the values were lost.TestResumedRequestConfigcloses as much of the rest as this repo can: itdrives foundation's own
apply_provider_preferences— the same function theapp layer calls to build a child's provider config — with what actually
crossed the seam, and asserts the resumed leg's effective provider/model
(
provider-anthropic/claude-opus-4) equals the spawn leg's. That is theconfig the first request would be issued against.
Spend
$0.00 — no API calls, no DTU, no infrastructure created. Cap for this
item was $0.
Deviations from a literal "thread two arguments"
_session_routingcache added. Without it the fix only helps a callerwho restates the role on every resume, which is not how the tool is used,
and the acceptance criterion is about a session spawned with a role.
Same lifetime/cold-cache caveat as
_session_agents; on a cold cache theapp layer's own recovery (agent overlay → persisted mount plan) takes over.
app-supplied callable would convert a silent downgrade into a hard
TypeErrorfor any app layer without feat(bundles): compose Context Intelligence by default #292.delegate:agent_resumed. Not requested. The dropsurvived this long because it was invisible in telemetry.
pytest tests/only, which excludesmodules/tool-delegate/tests— so this test would not run in CI asconfigured. Flagged, not changed here; worth its own item.
Lane DONE-NOTE (verbatim)
Committed in this PR at
docs/lanes/98a-foundation-resume-role/DONE-NOTE.md(commita8b5e73, present on the remote branch head). Inlined here so the measurements, spend, and deviations are readable in the PR body itself, not only by opening the file.docs/lanes/98a-foundation-resume-role/DONE-NOTE.md
DONE-NOTE — 98a: tool-delegate resume path drops model_role/provider_preferences
Item:
model_performance-98a(projectmodel_performance)Branch:
lane/98a-foundation-resume-roleParent commit measured against:
0d7b3f6e66953bcdafd4372c16f6694aab942292Result
DONE. The amplifier-foundation half of the "resume wipes the model role"
defect is fixed, with a test that fails on the parent commit and passes on
the fix. Full suite green. Draft PR opened. Nothing merged from this lane.
The two sites, quoted
Both at
modules/tool-delegate/amplifier_module_tool_delegate/__init__.py,line numbers as of the parent commit
0d7b3f6.Site 1 — the call site,
execute():1533-1539Why it drops the role. By the time control reaches this branch,
execute()has already resolved the caller'smodel_roleintoprovider_preferences(the resolver block at :1396-1490) — both values arelive local variables. The spawn branch 60 lines below passes both to
_spawn_new_session(... provider_preferences=..., raw_model_role=...). Thisbranch passes neither. The resolution work is done and then thrown away.
Site 2 — the resume path,
_resume_existing_session()Signature, :2113-2121 — no parameter exists to receive them even if the
call site had sent them:
The wire call to the app layer, :2199-2202:
Why it drops the role.
session.resumeis the app-layer capability —this call is the request tool-delegate makes of the app layer, and its
kwargs are what the app layer turns into the resumed session's provider
config. Two kwargs and no more. The app-layer half already accepts
provider_preferencesandmodel_role(amplifier-app-cli
session_spawner.py:911-926→resume_sub_session:1246-1251, shipped in #292 /
31ad917); this repo simply never sent them,so the app layer's promotion had nothing to promote and the resumed leg fell
back to settings priority. Silent: no error, no telemetry difference —
delegate:agent_spawnedcarriedmodel_role,delegate:agent_resumedcarried no routing field at all, so there was nothing to compare it against.
The two sites are one bug: the call site has the values and does not pass
them; the resume path could not accept them if it did.
What changed
provider_preferences+raw_model_role._resume_existing_sessionaccepts both; sends them toresume_fn._session_routingcache — records the routing each spawn resolvedto, keyed by sub_session_id, mirroring the existing
_session_agentscache. This is what makes the acceptance criterion hold for the shape
every real caller uses: routing is pinned once on the spawn call, and
resumes are then issued as
(session_id, instruction)with no routingargument at all. Caller-explicit routing on the resume call still wins.
_supported_resume_routing_kwargs()— introspects the app-layercapability and sends only what it declares. An older app layer taking
(sub_session_id, instruction)keeps working instead of dying on anunexpected kwarg; what it cannot do is drop the routing quietly — the
withheld kwargs are named in a
logger.warning.delegate:agent_resumednow carriesmodel_role/provider_preferences, same shape asdelegate:agent_spawned, so thetwo legs of a delegation are comparable in telemetry. Additive.
precedence rule.
Measured
Fail-before / pass-after. Same test file both times; the fix was stashed
for the "before" run, so only the module changed.
Before (parent
0d7b3f6, fix stashed) —docs/lanes/98a-foundation-resume-role/fail-before.txt:The 2 that passed before are the backward-compatibility tests
(
test_legacy_two_argument_resume_capability_still_works,test_plain_resume_sends_no_routing_kwargs) — they should pass before, andthey pin the behaviour the fix must not break.
After:
10 passed.Full suite:
1873 passed, 1 skipped, 1 warning in 19.45s(the warning ispre-existing, in
tests/test_subprocess_runner.py).Honest scope of "the wire"
The acceptance criterion says "the resumed session's first LLM request
carries the same model_role/provider_preferences". This repo does not build
LLM requests — it defines the capability contract and the app layer
implements it. So the strongest assertion available here is at the
session.resumeseam, which is where the values were being lost.TestResumedRequestConfigcloses as much of the remaining gap as this repocan: it drives foundation's own
apply_provider_preferences— the samefunction the app layer calls to build a child's provider config — with
whatever actually crossed the seam, and asserts the resumed leg's effective
provider/model (
provider-anthropic/claude-opus-4) equals the spawnleg's. That is the config the first request would be issued against. The
end-to-end wire capture that proves it against a live provider is rc0's, and
is what motivated this item; it is not re-run here (no spend authority).
Spend
$0.00. No API calls, no DTU, no infrastructure created, nothing to tear
down. The spend cap for this item was $0 and it was not approached: every
assertion is a local unit test against mocked capabilities. Nothing in the
remaining budget would have bought a stronger result at this layer — an
end-to-end wire re-capture would need a live provider and belongs with the
integration lane, not here.
Deviations
Added the
_session_routingspawn-time cache. Strictly more than"thread the two arguments through". Without it the fix only covers a
caller who restates the role on every resume call, which is not how the
tool is used — and the acceptance criterion is phrased about a session
spawned with a role, not a resume call carrying one. Same lifetime and
same cold-cache caveat as the existing
_session_agentscache; on a coldcache the app layer's own recovery (agent overlay → persisted mount plan)
takes over, so a cold cache is a fallback, not a regression.
Added the capability-signature guard. Unconditionally sending two new
kwargs to an app-layer-provided callable would have turned a silent
downgrade into a hard
TypeErrorfor any app layer that has not takenapp-cli feat(bundles): compose Context Intelligence by default #292. Guarded + warned instead. A callable that cannot be
introspected is treated as accepting nothing (preserve the working call
shape) and is also warned about — chosen deliberately over guessing and
crashing a resume.
Added routing fields to
delegate:agent_resumed. Not asked for. Thereason the drop survived this long is that it was invisible in telemetry;
leaving that gap open would leave the next regression equally invisible.
Additive — absence in an old capture means UNKNOWN, never "no routing".
CI runs
pytest tests/only, which does not includemodules/tool-delegate/tests. The localtestpathsdoes include it andthat is what was run for the green result above. Not changed here — out
of scope for a bug fix, and worth its own item.
Open
Neither alone closes the defect — a deployment needs both.
routing-matrixfix: use git URL for hooks-progress-monitor source #53 (dca54b5) remains defense-in-depth only; it does notsubstitute for either fix.
pytest tests/excludes the tool-delegate module tests, so thistest would not run in CI as configured. Flagged, not fixed.