mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01: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]>
200 lines
6.6 KiB
Go
200 lines
6.6 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
)
|
|
|
|
func TestEscalateModelToolMetadata(t *testing.T) {
|
|
tool := NewEscalateModelTool()
|
|
|
|
if tool.Name() != "escalate_model" {
|
|
t.Fatalf("name = %q, want escalate_model", tool.Name())
|
|
}
|
|
if tool.Description() == "" {
|
|
t.Fatal("description must not be empty")
|
|
}
|
|
|
|
safety := tool.Safety()
|
|
if safety.SideEffect != SideEffectNone {
|
|
t.Fatalf("side effect = %q, want none", safety.SideEffect)
|
|
}
|
|
if safety.Permission != PermissionAllow {
|
|
t.Fatalf("permission = %q, want allow", safety.Permission)
|
|
}
|
|
if !safety.AdvertiseInAuto {
|
|
t.Fatal("escalate_model must advertise in auto")
|
|
}
|
|
|
|
schema := tool.Parameters()
|
|
if schema.Type != "object" {
|
|
t.Fatalf("schema type = %q, want object", schema.Type)
|
|
}
|
|
if schema.AdditionalProperties {
|
|
t.Fatal("schema must disallow additional properties")
|
|
}
|
|
if len(schema.Required) != 0 {
|
|
t.Fatalf("escalate_model must have no required args, got %v", schema.Required)
|
|
}
|
|
if _, ok := schema.Properties["reason"]; !ok {
|
|
t.Fatal("schema must advertise an optional reason property")
|
|
}
|
|
}
|
|
|
|
// A non-string reason argument is a soft error, not a panic.
|
|
func TestEscalateModelToolRejectsNonStringReason(t *testing.T) {
|
|
tool := NewEscalateModelTool()
|
|
res := tool.Run(context.Background(), map[string]any{"reason": 42})
|
|
if res.Status != StatusError {
|
|
t.Fatalf("status = %q, want error for non-string reason", res.Status)
|
|
}
|
|
}
|
|
|
|
// Resolving against the real catalog: a known mid-tier model escalates to its
|
|
// configured stronger target and signals it via Meta["escalate_to_model"].
|
|
func TestEscalateModelRunSignalsTarget(t *testing.T) {
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
want, ok := registry.UpgradeTarget("claude-haiku-4.5")
|
|
if !ok {
|
|
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
|
}
|
|
|
|
tool := newEscalateModelTool(registry)
|
|
res := tool.RunWithOptions(context.Background(), map[string]any{"reason": "task is harder than expected"}, RunOptions{Model: "claude-haiku-4.5"})
|
|
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if got := res.Meta["escalate_to_model"]; got != want.ID {
|
|
t.Fatalf("meta escalate_to_model = %q, want %q", got, want.ID)
|
|
}
|
|
if !strings.Contains(res.Output, "claude-haiku-4.5") || !strings.Contains(res.Output, want.ID) {
|
|
t.Fatalf("output should name both models, got %q", res.Output)
|
|
}
|
|
if !strings.Contains(res.Output, "Escalating") {
|
|
t.Fatalf("output should announce escalation, got %q", res.Output)
|
|
}
|
|
}
|
|
|
|
// An explicitly empty reason (and an absent reason) is accepted: reason is
|
|
// informational only, so it must never fail the escalation. Both forms still
|
|
// return the escalation result with the target signalled via Meta.
|
|
func TestEscalateModelRunAcceptsEmptyReason(t *testing.T) {
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
target, ok := registry.UpgradeTarget("claude-haiku-4.5")
|
|
if !ok {
|
|
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
|
}
|
|
tool := newEscalateModelTool(registry)
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
}{
|
|
{name: "explicit empty reason", args: map[string]any{"reason": ""}},
|
|
{name: "absent reason", args: map[string]any{}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res := tool.RunWithOptions(context.Background(), tc.args, RunOptions{Model: "claude-haiku-4.5"})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q output = %q, want ok (empty reason must not error)", res.Status, res.Output)
|
|
}
|
|
if got := res.Meta["escalate_to_model"]; got != target.ID {
|
|
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A top-tier model (no upgrade target), an unknown model, and an empty model all
|
|
// produce an informational, no-meta, OK result — the loop must not switch.
|
|
func TestEscalateModelRunNoTarget(t *testing.T) {
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
tool := newEscalateModelTool(registry)
|
|
|
|
cases := []struct {
|
|
name string
|
|
model string
|
|
wantInOut string
|
|
}{
|
|
{name: "top-tier", model: "claude-opus-4.1", wantInOut: "claude-opus-4.1"},
|
|
{name: "unknown", model: "not-a-real-model", wantInOut: "not-a-real-model"},
|
|
{name: "empty", model: "", wantInOut: "current model"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: tc.model})
|
|
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if _, ok := res.Meta["escalate_to_model"]; ok {
|
|
t.Fatalf("no-target result must carry no escalate_to_model meta, got %v", res.Meta)
|
|
}
|
|
if !strings.Contains(res.Output, "no escalation performed") {
|
|
t.Fatalf("output should be informational, got %q", res.Output)
|
|
}
|
|
if !strings.Contains(res.Output, tc.wantInOut) {
|
|
t.Fatalf("output should name %q, got %q", tc.wantInOut, res.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sanity: the empty-args path (no reason) is valid and does not switch when on a
|
|
// top-tier model.
|
|
func TestEscalateModelRunDefaultsToNoEscalation(t *testing.T) {
|
|
tool := newEscalateModelTool(modelregistry.Registry{})
|
|
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: "claude-opus-4.1"})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if len(res.Meta) != 0 {
|
|
t.Fatalf("empty registry must never signal a target, got meta %v", res.Meta)
|
|
}
|
|
}
|
|
|
|
// End-to-end through the Registry: RunWithOptions must route to the tool's
|
|
// optionsAwareTool branch (threading options.Model) and, because the tool is
|
|
// PermissionAllow, run without a permission grant.
|
|
func TestEscalateModelThroughRegistryDispatch(t *testing.T) {
|
|
modelReg, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
target, ok := modelReg.UpgradeTarget("claude-haiku-4.5")
|
|
if !ok {
|
|
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
|
|
}
|
|
|
|
reg := NewRegistry()
|
|
reg.Register(newEscalateModelTool(modelReg))
|
|
|
|
res := reg.RunWithOptions(context.Background(), "escalate_model", map[string]any{}, RunOptions{
|
|
Model: "claude-haiku-4.5",
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q output = %q", res.Status, res.Output)
|
|
}
|
|
if got := res.Meta["escalate_to_model"]; got != target.ID {
|
|
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
|
|
}
|
|
if !strings.Contains(res.Output, target.ID) {
|
|
t.Fatalf("output should name target %q, got %q", target.ID, res.Output)
|
|
}
|
|
}
|