* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup
* modelregistry: seed escalation chains in catalog decoration
* modelregistry: verification gate for escalation upgrade map
* agent: add ModelSwitcher option and ToolResult.RequestedModel field
* agent: lift escalate_to_model meta into ToolResult.RequestedModel
* agent: perform at-most-one mid-run model switch in the turn loop
* tools: add SideEffectNone for control-only tools
* tools: add escalate_model control tool (metadata + args)
* cli: parse --allow-escalation exec flag
* cli: document --allow-escalation in exec help
* cli: register escalate_model tool under --allow-escalation
* cli: wire model switcher for exec escalation
* cli: guard byte-identical exec run without escalation flag
* cli: attribute post-switch usage to the escalated model
* tools: accept an explicit empty escalate_model reason
reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.
* agent: test (nil,nil) escalation switcher keeps original provider
When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.
Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).
* cli: gate usage model attribution on the escalation flag and harden tests
FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).
Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
own turn counter; assert the source provider served exactly the
escalation turn and the escalated provider served exactly the
post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
first attributes to the original model, the second to the escalated
target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
top-tier performs no switch (newProvider built exactly once) and
succeeds.
* modelregistry: validate UpgradeTargetID resolves at registry construction
* cli: update currentModel only when the switched provider is non-nil
* sandbox: make 'none' a first-class side effect (control-only tools)
---------
Co-authored-by: Gnanam <[email protected]>