mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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]>
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
|
|
// assignable field on Options with the agreed signature, and that nil is its
|
|
// zero value (escalation disabled by default).
|
|
func TestOptionsModelSwitcherFieldExists(t *testing.T) {
|
|
var options Options
|
|
if options.ModelSwitcher != nil {
|
|
t.Fatalf("expected ModelSwitcher to default to nil, got non-nil")
|
|
}
|
|
options.ModelSwitcher = func(_ context.Context, modelID string) (Provider, error) {
|
|
return &mockProvider{}, nil
|
|
}
|
|
provider, err := options.ModelSwitcher(context.Background(), "claude-sonnet-4.5")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := provider.(*mockProvider); !ok {
|
|
t.Fatalf("expected ModelSwitcher to return a Provider, got %T", provider)
|
|
}
|
|
}
|
|
|
|
// TestToolResultRequestedModelFieldExists asserts the loop-level escalation
|
|
// signal field is present on ToolResult and empty for a normal result.
|
|
func TestToolResultRequestedModelFieldExists(t *testing.T) {
|
|
var result ToolResult
|
|
if result.RequestedModel != "" {
|
|
t.Fatalf("expected RequestedModel to default to empty, got %q", result.RequestedModel)
|
|
}
|
|
result.RequestedModel = "claude-opus-4.1"
|
|
if result.RequestedModel != "claude-opus-4.1" {
|
|
t.Fatalf("expected RequestedModel to round-trip, got %q", result.RequestedModel)
|
|
}
|
|
// Keep the zeroruntime import load-bearing so the file compiles standalone.
|
|
_ = zeroruntime.MessageRoleAssistant
|
|
}
|
|
|
|
// escalatingTool is a registered fake tool that returns the escalation signal
|
|
// in result Meta (mirroring the real escalate_model tool's contract), used to
|
|
// drive the loop-level switch in tests without depending on the tools package.
|
|
type escalatingTool struct {
|
|
target string
|
|
}
|
|
|
|
func (t escalatingTool) Name() string { return "escalate" }
|
|
func (t escalatingTool) Description() string { return "requests a model switch for testing" }
|
|
func (t escalatingTool) Parameters() tools.Schema {
|
|
return tools.Schema{Type: "object", AdditionalProperties: false}
|
|
}
|
|
func (t escalatingTool) Safety() tools.Safety {
|
|
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
|
}
|
|
func (t escalatingTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
|
meta := map[string]string{}
|
|
if t.target != "" {
|
|
meta["escalate_to_model"] = t.target
|
|
}
|
|
return tools.Result{Status: tools.StatusOK, Output: "escalating", Meta: meta}
|
|
}
|
|
|
|
// TestExecuteToolCallLiftsEscalationMeta verifies executeToolCall promotes the
|
|
// tool's Meta["escalate_to_model"] into ToolResult.RequestedModel.
|
|
func TestExecuteToolCallLiftsEscalationMeta(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
result, abortErr := executeToolCall(
|
|
context.Background(),
|
|
registry,
|
|
ToolCall{ID: "c1", Name: "escalate"},
|
|
PermissionModeAuto,
|
|
Options{},
|
|
)
|
|
if abortErr != nil {
|
|
t.Fatal(abortErr)
|
|
}
|
|
if result.RequestedModel != "claude-opus-4.1" {
|
|
t.Fatalf("expected RequestedModel lifted from meta, got %q", result.RequestedModel)
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty verifies a normal
|
|
// tool result (no escalate_to_model meta) leaves RequestedModel empty.
|
|
func TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: ""})
|
|
|
|
result, abortErr := executeToolCall(
|
|
context.Background(),
|
|
registry,
|
|
ToolCall{ID: "c1", Name: "escalate"},
|
|
PermissionModeAuto,
|
|
Options{},
|
|
)
|
|
if abortErr != nil {
|
|
t.Fatal(abortErr)
|
|
}
|
|
if result.RequestedModel != "" {
|
|
t.Fatalf("expected empty RequestedModel without escalation meta, got %q", result.RequestedModel)
|
|
}
|
|
}
|
|
|
|
// escalateThenAnswerTurns builds a two-turn provider script: turn 1 calls the
|
|
// escalate tool, turn 2 returns a final answer. Reused by the switch tests.
|
|
func escalateThenAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
|
|
return [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: answer},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestRunSwitchesProviderOnEscalationRequest verifies that when a tool requests
|
|
// a model and a ModelSwitcher is wired, the loop swaps the active provider (the
|
|
// NEXT turn streams from the new provider) and updates options.Model.
|
|
func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
firstProvider := &mockProvider{turns: escalateThenAnswerTurns("done")}
|
|
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "answered on the upgraded model"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
|
|
var switchedTo string
|
|
switchCount := 0
|
|
result, err := Run(context.Background(), "go", firstProvider, Options{
|
|
Registry: registry,
|
|
Model: "claude-sonnet-4.5",
|
|
MaxTurns: 4,
|
|
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
|
switchCount++
|
|
switchedTo = modelID
|
|
return secondProvider, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if switchCount != 1 {
|
|
t.Fatalf("expected exactly one switch, got %d", switchCount)
|
|
}
|
|
if switchedTo != "claude-opus-4.1" {
|
|
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
|
|
}
|
|
// The first provider only handled the escalation turn (1 request); the second
|
|
// provider handled the answer turn (1 request) — proving the swap took effect.
|
|
if len(firstProvider.requests) != 1 {
|
|
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
|
}
|
|
if len(secondProvider.requests) != 1 {
|
|
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
|
}
|
|
if result.FinalAnswer != "answered on the upgraded model" {
|
|
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
|
}
|
|
}
|
|
|
|
// TestRunIgnoresEscalationWhenSwitcherNil verifies that without a ModelSwitcher
|
|
// the escalation request is ignored: the original provider serves every turn.
|
|
func TestRunIgnoresEscalationWhenSwitcherNil(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
provider := &mockProvider{turns: escalateThenAnswerTurns("done on original")}
|
|
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: registry,
|
|
Model: "claude-sonnet-4.5",
|
|
MaxTurns: 4,
|
|
// ModelSwitcher intentionally nil.
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Both turns (escalate + answer) must run on the single original provider.
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
|
|
}
|
|
if result.FinalAnswer != "done on original" {
|
|
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
|
|
}
|
|
}
|
|
|
|
// TestRunEscalationSwitcherErrorIsNonFatal verifies a ModelSwitcher error does
|
|
// not abort the run: the loop continues on the current provider and a note is
|
|
// recorded in the transcript.
|
|
func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
|
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: registry,
|
|
Model: "claude-sonnet-4.5",
|
|
MaxTurns: 4,
|
|
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
|
return nil, errors.New("provider build blew up")
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected escalation error to be non-fatal, got %v", err)
|
|
}
|
|
// The run continues on the same provider through the answer turn.
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
|
}
|
|
if result.FinalAnswer != "recovered" {
|
|
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
|
}
|
|
// A brief note about the failed switch must reach the model on the next turn.
|
|
var sawNote bool
|
|
for _, m := range provider.requests[1].Messages {
|
|
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(m.Content), "could not switch") {
|
|
sawNote = true
|
|
}
|
|
}
|
|
if !sawNote {
|
|
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", provider.requests[1].Messages)
|
|
}
|
|
}
|
|
|
|
// TestRunEscalationSwitcherNilProviderKeepsOriginal verifies that when a
|
|
// ModelSwitcher returns (nil, nil) — no error, but also no replacement provider —
|
|
// the loop does NOT null out the active provider. The run must continue on the
|
|
// ORIGINAL provider and return a result rather than panic on a nil provider.
|
|
func TestRunEscalationSwitcherNilProviderKeepsOriginal(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
provider := &mockProvider{turns: escalateThenAnswerTurns("answered on original")}
|
|
|
|
switchCount := 0
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: registry,
|
|
Model: "claude-sonnet-4.5",
|
|
MaxTurns: 4,
|
|
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
|
switchCount++
|
|
// No error, but no provider either: the loop must NOT swap to nil.
|
|
return nil, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected (nil,nil) switch to be non-fatal, got %v", err)
|
|
}
|
|
if switchCount != 1 {
|
|
t.Fatalf("expected exactly one switch attempt, got %d", switchCount)
|
|
}
|
|
// Both turns (escalate + answer) must run on the single original provider; if
|
|
// the loop nil'd out the provider on a (nil,nil) return, the second turn would
|
|
// panic or never run.
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
|
|
}
|
|
if result.FinalAnswer != "answered on original" {
|
|
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
|
|
}
|
|
}
|
|
|
|
// TestRunSwitchesAtMostOncePerTurn verifies that when a single turn carries two
|
|
// escalation requests, only the first triggers a switch.
|
|
func TestRunSwitchesAtMostOncePerTurn(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
|
|
|
firstProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "escalate"},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
|
|
switchCount := 0
|
|
if _, err := Run(context.Background(), "go", firstProvider, Options{
|
|
Registry: registry,
|
|
Model: "claude-sonnet-4.5",
|
|
MaxTurns: 4,
|
|
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
|
switchCount++
|
|
return secondProvider, nil
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if switchCount != 1 {
|
|
t.Fatalf("expected at most one switch per turn, got %d", switchCount)
|
|
}
|
|
}
|