mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c) * fix(execprofile): harden profiles against review findings - fast escalates on critical-risk mutations, not high: the sandbox marks every shell command high before command analysis, so a high trigger spent the one-shot on the first go test of any task - escalation can now restore the provider-default effort a profile displaced (PostureEscalation.RestoreDefaultEffort; a plain empty target means leave untouched) - TUI /turns while a profile is active pins the budget: disarms the escalation turn target (mirroring exec's explicit --max-turns rule) and profile reverts use touched bits instead of value equality, so explicit choices survive even when they coincide with profile values - spec-draft runs get the same escalation policy as the main run instead of the displaced budget with no safety net - bench validates and canonicalizes --exec-profile before spawning: an unknown name previously produced an exit-0 report full of millisecond usage-error walls - turn summary prints exec-profile alongside mode; zero exec rejects an inline-empty --exec-profile=; balanced trace label documented and pinned as deliberate attribution * fix(execprofile): explicit effort choices disarm the escalation effort restore An explicit effort set AFTER profile selection could be cleared by a mid-run escalation (the effort analog of the /turns pin, introduced by RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on a policy clone, and the spec-draft path only arms it when no explicit --spec-reasoning-effort governs the draft. * fix(execprofile): address review findings on the profile surfaces - --use-spec projects away a profile's self-correct knob instead of silently arming an option the draft runner ignores (the explicit --self-correct --use-spec combination is already a parse-time error) - RunTurnBench canonicalizes and validates ExecProfile at the library boundary, so a direct caller's casing/whitespace cannot fork report labels and an unknown name fails before anything runs - reverting a profile whose displaced budget was zero clears ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have kept the removed profile's budget) - the TUI effort-disarm test fails instead of skipping when the catalog stops filling low effort * fix(tui): reconcile an active profile's effort fill on model switch The fill is per-model, not a session preference: fast selected on a model without low fills nothing, and switching to a model that supports it should behave like selecting the profile there; the reverse switch must not keep sending a level the destination does not support. Both switch paths (the /model command and the cross-provider picker) now re-derive the fill, leaving explicitly touched efforts alone and keeping the escalation's effort restore in step. Also: the revert-env test asserts key removal via LookupEnv, and applyProfileTurnBudget's pinned-budget parameter is named and documented as covering mode presets, not just the flag. * fix(tui): void profile effort bookkeeping when the model-switch drop erases it The /model path's pre-existing unsupported-effort drop ran before the profile reconciliation and left contradictory state behind: a dropped touched override kept blocking the profile from governing, and a dropped profile fill left a stale applied value with the effort restore still armed. The drop itself is load-bearing (the TUI run path forwards the effort to the provider unfiltered), so instead of preserving a known-unsupported level, the voided choice now resets the profile's effort bookkeeping and the reconcile re-derives from scratch. The whole sequence lives in one ring-parameterized helper driven by the switch target's authoritative ring, unit-tested against synthetic rings for both the void-then-refill arc and the supported-override survival. * fix(tui): only drop an explicit effort when the target ring is authoritative A nil ring meant both a catalog model with no effort controls and a live-discovered custom model the catalog cannot vouch for. The drop now requires an authoritative ring (a resolved catalog entry, whose ring may legitimately be empty); an unknown ring preserves an explicit preference, matching the cross-provider picker's behavior, while the profile's own fill still applies only where support is known.
346 lines
13 KiB
Go
346 lines
13 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/trace"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
|
|
for _, controller := range []*profileController{
|
|
newProfileController(nil),
|
|
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
|
|
} {
|
|
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
|
controller.observeUncertain()
|
|
controller.observeSelfCorrect(OutcomeAborted)
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("nil policy must never escalate")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
|
|
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("streak below threshold must not escalate")
|
|
}
|
|
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
|
|
target, fired := controller.maybeEscalate()
|
|
if !fired || target.MaxTurns != 80 {
|
|
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
|
|
// Medium risk on an executed result: below threshold.
|
|
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("medium risk must not trip a high threshold")
|
|
}
|
|
// Critical risk on a DENIED result: never counts (the mutation never ran).
|
|
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("a denied result must not trip the risky-mutation signal")
|
|
}
|
|
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
|
|
// the mutation ran, so it counts.
|
|
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
|
if _, fired := controller.maybeEscalate(); !fired {
|
|
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
|
|
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
|
|
controller.observeSelfCorrect(OutcomePassed)
|
|
controller.observeSelfCorrect(OutcomeDisabled)
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("passing/disabled outcomes must not escalate")
|
|
}
|
|
controller.observeSelfCorrect(OutcomeReported)
|
|
if _, fired := controller.maybeEscalate(); !fired {
|
|
t.Fatal("a failing verification outcome must escalate")
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
|
|
controller.observeUncertain()
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
|
|
}
|
|
controller.observeUncertain()
|
|
if _, fired := controller.maybeEscalate(); !fired {
|
|
t.Fatal("second uncertain evaluation must escalate")
|
|
}
|
|
}
|
|
|
|
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
|
|
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
|
|
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
|
if _, fired := controller.maybeEscalate(); !fired {
|
|
t.Fatal("expected first escalation")
|
|
}
|
|
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
|
controller.observeUncertain()
|
|
if _, fired := controller.maybeEscalate(); fired {
|
|
t.Fatal("escalation must be one-shot per run")
|
|
}
|
|
}
|
|
|
|
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
|
|
// point: the completion gate's continue branch escapes the end-of-turn tail,
|
|
// so escalation must apply before the continue. A cue turn under the gate
|
|
// counts as uncertain, escalates, and the very next request carries the target
|
|
// effort.
|
|
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
|
|
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: tools.NewRegistry(),
|
|
MaxTurns: 10,
|
|
RequireCompletionSignal: true,
|
|
Trace: recorder,
|
|
Profile: &ProfilePolicy{
|
|
Name: "fast",
|
|
Escalate: &PostureEscalation{
|
|
ReasoningEffort: "high",
|
|
OnCompletionUncertain: 1,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "Done. All set." {
|
|
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
|
}
|
|
if provider.requests[1].ReasoningEffort != "high" {
|
|
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
|
|
}
|
|
tr := recorder.Finish()
|
|
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
|
t.Fatalf("posture_escalations = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// A profile that filled the run's effort displaced the provider default ("");
|
|
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
|
|
// ReasoningEffort target cannot express (it means "leave untouched").
|
|
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: tools.NewRegistry(),
|
|
MaxTurns: 10,
|
|
ReasoningEffort: "low",
|
|
RequireCompletionSignal: true,
|
|
Profile: &ProfilePolicy{
|
|
Name: "fast",
|
|
Escalate: &PostureEscalation{
|
|
RestoreDefaultEffort: true,
|
|
OnCompletionUncertain: 1,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "Done. All set." {
|
|
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
|
}
|
|
if provider.requests[0].ReasoningEffort != "low" {
|
|
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
|
|
}
|
|
if provider.requests[1].ReasoningEffort != "" {
|
|
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
|
|
}
|
|
}
|
|
|
|
// failingProfileTool always errors with the same signature so the repeated-
|
|
// failure guard builds a streak.
|
|
type failingProfileTool struct{}
|
|
|
|
func (failingProfileTool) Name() string { return "flaky_probe" }
|
|
func (failingProfileTool) Description() string { return "always fails identically (test)" }
|
|
func (failingProfileTool) Parameters() tools.Schema {
|
|
return tools.Schema{Type: "object", AdditionalProperties: false}
|
|
}
|
|
func (failingProfileTool) Safety() tools.Safety {
|
|
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
|
}
|
|
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
|
|
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
|
|
}
|
|
|
|
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
|
|
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
|
|
// finishes on a turn the original ceiling would have cut off, with the effort
|
|
// target applied to the post-escalation request and the counter emitted.
|
|
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(failingProfileTool{})
|
|
|
|
toolTurn := []zeroruntime.StreamEvent{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
}
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
toolTurn,
|
|
toolTurn,
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
}}
|
|
|
|
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: registry,
|
|
MaxTurns: 2,
|
|
Trace: recorder,
|
|
Profile: &ProfilePolicy{
|
|
Name: "fast",
|
|
Escalate: &PostureEscalation{
|
|
MaxTurns: 4,
|
|
ReasoningEffort: "high",
|
|
OnToolFailureStreak: 2,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "recovered after escalation" {
|
|
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
|
|
}
|
|
if len(provider.requests) != 3 {
|
|
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
|
|
}
|
|
if provider.requests[2].ReasoningEffort != "high" {
|
|
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
|
|
}
|
|
tr := recorder.Finish()
|
|
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
|
t.Fatalf("posture_escalations = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
|
|
// the loop level: the identical failing script without a profile ends at the
|
|
// original ceiling with the max-turns answer.
|
|
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(failingProfileTool{})
|
|
|
|
toolTurn := []zeroruntime.StreamEvent{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
}
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
|
|
|
|
result, err := Run(context.Background(), "go", provider, Options{
|
|
Registry: registry,
|
|
MaxTurns: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(provider.requests) > 3 {
|
|
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
|
|
}
|
|
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
|
|
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
|
|
// stamp on the unsandboxed path: a read-only tool classifies low.
|
|
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(failingProfileTool{})
|
|
|
|
result, abortErr := executeToolCall(
|
|
context.Background(),
|
|
registry,
|
|
ToolCall{ID: "c1", Name: "flaky_probe"},
|
|
PermissionModeAuto,
|
|
Options{},
|
|
)
|
|
if abortErr != nil {
|
|
t.Fatal(abortErr)
|
|
}
|
|
if result.Risk.Level == "" {
|
|
t.Fatal("executed result must carry a risk classification")
|
|
}
|
|
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
|
|
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
|
|
}
|
|
}
|
|
|
|
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
|
|
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
|
|
// or inventing a classification.
|
|
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
result, abortErr := executeToolCall(
|
|
context.Background(),
|
|
registry,
|
|
ToolCall{ID: "c1", Name: "no_such_tool"},
|
|
PermissionModeAuto,
|
|
Options{},
|
|
)
|
|
if abortErr != nil {
|
|
t.Fatal(abortErr)
|
|
}
|
|
if result.Status == tools.StatusOK {
|
|
t.Fatal("expected an error result for an unknown tool")
|
|
}
|
|
if result.Risk.Level != "" {
|
|
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
|
|
}
|
|
}
|