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]>
84 lines
2.4 KiB
Go
84 lines
2.4 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var autonomyRank = map[Autonomy]int{
|
|
AutonomyLow: 0,
|
|
AutonomyMedium: 1,
|
|
AutonomyHigh: 2,
|
|
}
|
|
|
|
func NormalizeAutonomy(value Autonomy) (Autonomy, error) {
|
|
normalized := Autonomy(strings.ToLower(strings.TrimSpace(string(value))))
|
|
switch normalized {
|
|
case "", AutonomyLow:
|
|
return AutonomyLow, nil
|
|
case AutonomyMedium, AutonomyHigh:
|
|
return normalized, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid sandbox autonomy %q. Expected low, medium, or high", value)
|
|
}
|
|
}
|
|
|
|
func NormalizePermissionMode(value PermissionMode) PermissionMode {
|
|
normalized := PermissionMode(strings.ToLower(strings.TrimSpace(string(value))))
|
|
switch normalized {
|
|
case PermissionModeAsk:
|
|
return PermissionModeAsk
|
|
case PermissionUnsafe:
|
|
return PermissionUnsafe
|
|
default:
|
|
return PermissionModeAuto
|
|
}
|
|
}
|
|
|
|
func NormalizePermission(value Permission) Permission {
|
|
normalized := Permission(strings.ToLower(strings.TrimSpace(string(value))))
|
|
switch normalized {
|
|
case PermissionAllow, PermissionDeny:
|
|
return normalized
|
|
default:
|
|
return PermissionPrompt
|
|
}
|
|
}
|
|
|
|
func NormalizeSideEffect(value SideEffect) SideEffect {
|
|
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
|
|
switch normalized {
|
|
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
|
|
return normalized
|
|
default:
|
|
return SideEffectOutOfWorkspace
|
|
}
|
|
}
|
|
|
|
func NormalizeGrantDecision(value GrantDecision) (GrantDecision, error) {
|
|
normalized := GrantDecision(strings.ToLower(strings.TrimSpace(string(value))))
|
|
switch normalized {
|
|
case GrantAllow, GrantDeny:
|
|
return normalized, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid sandbox grant decision %q. Expected allow or deny", value)
|
|
}
|
|
}
|
|
|
|
func autonomyAllowed(requested Autonomy, max Autonomy) bool {
|
|
requestedNorm, requestedErr := NormalizeAutonomy(requested)
|
|
maxNorm, maxErr := NormalizeAutonomy(max)
|
|
// An unknown requested tier ranks above every valid tier so it is never
|
|
// allowed (fail-closed), even against a High ceiling. An unknown ceiling
|
|
// ranks below every valid tier so it admits nothing above Low (fail-closed).
|
|
requestedScore := autonomyRank[requestedNorm]
|
|
if requestedErr != nil {
|
|
requestedScore = autonomyRank[AutonomyHigh] + 1
|
|
}
|
|
maxScore := autonomyRank[maxNorm]
|
|
if maxErr != nil {
|
|
maxScore = autonomyRank[AutonomyLow] - 1
|
|
}
|
|
return requestedScore <= maxScore
|
|
}
|