-
Notifications
You must be signed in to change notification settings - Fork 176
fix(agent): stop a denied tool looping past the repeated-failure halt #866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Vasanthdev2004
wants to merge
12
commits into
main
Choose a base branch
from
fix/guardrail-denial-counter-rekey
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,521
−45
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
1eb8c22
fix(agent): stop a denied tool looping past the repeated-failure halt
Vasanthdev2004 0362feb
fix(agent): count denials as failures and report the bound that tripped
Vasanthdev2004 c41fcb4
fix(agent): count uncategorized policy refusals in the halt guard
Vasanthdev2004 2904ca8
fix(agent): stop the halt answer claiming a pattern the counters do n…
Vasanthdev2004 e73e658
fix(agent): do not classify successful tool output as a policy refusal
Vasanthdev2004 a74d2b2
test(agent): drive the uncategorized refusal paths through Run
Vasanthdev2004 12fad62
fix(agent): classify a policy refusal from provenance, never from too…
Vasanthdev2004 30f255c
fix(tools): give capture_artifact's configuration refusals the same p…
Vasanthdev2004 6c061fe
fix(agent): give a marker-only refusal the same identity the guard ke…
Vasanthdev2004 2c2ca4e
fix(agent): type the refusal identity and carry the halt to terminal …
Vasanthdev2004 10c43f4
fix(agent): close out advertised tool calls by what actually happened
Vasanthdev2004 19c839d
fix(agent): authenticate refusal provenance and separate completion f…
Vasanthdev2004 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package agent | ||
|
|
||
| import ( | ||
| "context" | ||
| "strconv" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/tools" | ||
| "github.com/Gitlawb/zero/internal/zeroruntime" | ||
| ) | ||
|
|
||
| // A CONFIGURATION REFUSAL IS NOT A RETRIABLE FAILURE, EVEN WHEN IT ARRIVES | ||
| // EARLY. | ||
| // | ||
| // capture_artifact rejects in RejectBeforePermission, which the registry returns | ||
| // straight back before any of the gates that attach provenance. Its | ||
| // valid-but-unavailable calls therefore reached the classifier with no denial | ||
| // category, no permission metadata and no refusal marker, and were read as | ||
| // ordinary retriable failures: the model got the schema hint and the call could | ||
| // consume the profile failure-streak escalation, for a tool that never executed | ||
| // and that no argument change can enable. | ||
| func TestDisabledCaptureArtifactIsAPolicyRefusalNotARetriableFailure(t *testing.T) { | ||
| // No artifacts directory and no enabled driver: valid arguments, unavailable | ||
| // tool. This is the shape an operator produces by configuration alone. | ||
| registry := tools.NewRegistry() | ||
| for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{}) { | ||
| registry.Register(tool) | ||
| } | ||
|
|
||
| result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{ | ||
| "action": "browser_screenshot", | ||
| "name": "shot", | ||
| }, tools.RunOptions{PermissionGranted: true}) | ||
|
|
||
| if result.Status != tools.StatusError { | ||
| t.Fatalf("SETUP INVALID: expected the disabled tool to refuse, got %s: %s", result.Status, result.Output) | ||
| } | ||
| if !tools.IsPolicyRefusalResult(result) { | ||
| t.Fatalf("the refusal carries no provenance, so nothing downstream can tell it from a failed command: %#v", result.Meta) | ||
| } | ||
|
|
||
| // Through the conversion the loop performs, which is where the marker has to | ||
| // survive to be worth anything. | ||
| converted := ToolResult{ | ||
| Status: result.Status, | ||
| Output: result.Output, | ||
| Meta: result.Meta, | ||
| } | ||
| if !isPolicyRefusal(converted) { | ||
| t.Error("the marker did not survive into the agent-facing result") | ||
| } | ||
| if isRetriableToolError(converted) { | ||
| t.Error("a tool that never executed, and that no argument change can enable, was marked retriable: the model gets a schema hint telling it to fix arguments that were already valid") | ||
| } | ||
| } | ||
|
|
||
| // The malformed-argument branch must stay retriable. That one IS fixable by | ||
| // trying again differently, which is what the hint exists for, so marking every | ||
| // early rejection would trade one wrong answer for another. | ||
| func TestMalformedCaptureArtifactArgumentsStayRetriable(t *testing.T) { | ||
| registry := tools.NewRegistry() | ||
| for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{}) { | ||
| registry.Register(tool) | ||
| } | ||
|
|
||
| result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{ | ||
| "action": "not_a_real_action", | ||
| }, tools.RunOptions{PermissionGranted: true}) | ||
|
|
||
| if result.Status != tools.StatusError { | ||
| t.Fatalf("SETUP INVALID: expected invalid arguments to fail, got %s", result.Status) | ||
| } | ||
| if tools.IsPolicyRefusalResult(result) { | ||
| t.Fatalf("a malformed-argument error was marked a policy refusal, so the model is denied the hint that would let it fix the call: %q", result.Output) | ||
| } | ||
| converted := ToolResult{Status: result.Status, Output: result.Output, Meta: result.Meta} | ||
| if !isRetriableToolError(converted) { | ||
| t.Error("invalid arguments should stay retriable") | ||
| } | ||
| } | ||
|
|
||
| // alwaysRefusingCaptureTool stands in for the disabled tool at Run level, so the | ||
| // loop consequence can be observed rather than inferred. | ||
| type alwaysRefusingCaptureTool struct{ ran int } | ||
|
|
||
| func (tool *alwaysRefusingCaptureTool) Name() string { return "capture_artifact" } | ||
| func (tool *alwaysRefusingCaptureTool) Description() string { return "test capture tool" } | ||
| func (tool *alwaysRefusingCaptureTool) Parameters() tools.Schema { | ||
| return tools.Schema{ | ||
| Type: "object", | ||
| Properties: map[string]tools.PropertySchema{"action": {Type: "string"}}, | ||
| Required: []string{"action"}, | ||
| AdditionalProperties: false, | ||
| } | ||
| } | ||
| func (tool *alwaysRefusingCaptureTool) Safety() tools.Safety { | ||
| return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "captures artifacts"} | ||
| } | ||
| func (tool *alwaysRefusingCaptureTool) Run(context.Context, map[string]any) tools.Result { | ||
| tool.ran++ | ||
| return tools.Result{} | ||
| } | ||
| func (tool *alwaysRefusingCaptureTool) RejectBeforePermission(map[string]any) (tools.Result, bool) { | ||
| return tools.Result{ | ||
| Status: tools.StatusError, | ||
| Output: "Error: capture_artifact is disabled because no artifact directory is configured.", | ||
| Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalToolNotEnabled}, | ||
| }, true | ||
| } | ||
|
|
||
| // THE LOOP CONSEQUENCE. A refusal must not draw the retry hint, because the hint | ||
| // tells the model to fix arguments that were already valid and the tool will | ||
| // refuse identically next time. | ||
| func TestRunDoesNotHintARefusedCaptureArtifact(t *testing.T) { | ||
| tool := &alwaysRefusingCaptureTool{} | ||
| registry := tools.NewRegistry() | ||
| registry.Register(tool) | ||
|
|
||
| calls := toolFailureHintAt + 1 | ||
| turns := make([][]zeroruntime.StreamEvent, 0, calls+1) | ||
| for i := range calls { | ||
| turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "capture_artifact", `{"action":"browser_screenshot"}`)) | ||
| } | ||
| turns = append(turns, textTurn("gave up on the screenshot")) | ||
|
|
||
| result, err := Run(context.Background(), "take a screenshot", &mockProvider{turns: turns}, Options{ | ||
| Registry: registry, | ||
| PermissionMode: PermissionModeAsk, | ||
| MaxTurns: len(turns) + 5, | ||
| }) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if tool.ran != 0 { | ||
| t.Fatalf("SETUP INVALID: the tool executed %d times; it must be refused before Run", tool.ran) | ||
| } | ||
| for _, message := range result.Messages { | ||
| if strings.Contains(message.Content, toolFailureHintMarker) { | ||
| t.Fatal("a refused, never-executed tool drew the retry hint, which tells the model to fix arguments that were already valid") | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| package agent | ||
|
|
||
| import ( | ||
| "context" | ||
| "strconv" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/tools" | ||
| "github.com/Gitlawb/zero/internal/zeroruntime" | ||
| ) | ||
|
|
||
| // alternatingRefusalCaptureTool refuses every call with the same CATEGORY and a | ||
| // different MESSAGE, which is what a real disabled driver does: the refusal | ||
| // names the action, and the model is free to alternate valid actions. | ||
| type alternatingRefusalCaptureTool struct{ ran int } | ||
|
|
||
| func (tool *alternatingRefusalCaptureTool) Name() string { return "capture_artifact" } | ||
| func (tool *alternatingRefusalCaptureTool) Description() string { return "test capture tool" } | ||
| func (tool *alternatingRefusalCaptureTool) Parameters() tools.Schema { | ||
| return tools.Schema{ | ||
| Type: "object", | ||
| Properties: map[string]tools.PropertySchema{"action": {Type: "string"}}, | ||
| Required: []string{"action"}, | ||
| AdditionalProperties: false, | ||
| } | ||
| } | ||
| func (tool *alternatingRefusalCaptureTool) Safety() tools.Safety { | ||
| return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "captures artifacts"} | ||
| } | ||
| func (tool *alternatingRefusalCaptureTool) Run(context.Context, map[string]any) tools.Result { | ||
| tool.ran++ | ||
| return tools.Result{} | ||
| } | ||
| func (tool *alternatingRefusalCaptureTool) RejectBeforePermission(args map[string]any) (tools.Result, bool) { | ||
| action, _ := args["action"].(string) | ||
| return tools.Result{ | ||
| Status: tools.StatusError, | ||
| // Action-specific wording, same category. This is the whole point: the | ||
| // prose differs on every call while the refusal never changes. | ||
| Output: "Error: Local control driver for " + action + " is disabled.", | ||
| Meta: map[string]string{tools.PolicyRefusalMeta: tools.PolicyRefusalToolNotEnabled}, | ||
| }, true | ||
| } | ||
|
|
||
| // A REFUSAL KEYS ON ITS CATEGORY, INCLUDING WHEN THE CATEGORY IS ONLY A MARKER. | ||
| // | ||
| // The registry marks pre-execution refusals in metadata, and isPolicyRefusal | ||
| // read that marker while observeToolResult keyed on DenialReason, which those | ||
| // paths leave empty. The guard fell back to errorSignature(output), so two | ||
| // refusals of the same category with different wording looked like two | ||
| // different failures and the streak restarted at 1 every call. | ||
| // | ||
| // A model alternating capture_artifact's browser_screenshot and browser_pdf | ||
| // against a disabled driver is refused identically each time and never tripped | ||
| // the six-call halt. Only the generic twelve-error fallback stopped the run, | ||
| // reporting varied errors rather than a repeated refusal. | ||
| func TestAlternatingRefusedActionsStillTripTheRefusalHalt(t *testing.T) { | ||
| tool := &alternatingRefusalCaptureTool{} | ||
| registry := tools.NewRegistry() | ||
| registry.Register(tool) | ||
|
|
||
| // More calls than the refusal halt but FEWER than the generic any-error | ||
| // fallback, so only the category-keyed streak can stop this. | ||
| calls := toolFailureAnyErrorStopAt - 1 | ||
| if calls <= toolFailureStopAt { | ||
| t.Fatalf("SETUP INVALID: %d calls cannot distinguish the refusal halt from the generic fallback", calls) | ||
| } | ||
| actions := []string{"browser_screenshot", "browser_pdf"} | ||
| turns := make([][]zeroruntime.StreamEvent, 0, calls+1) | ||
| for i := range calls { | ||
| action := actions[i%len(actions)] | ||
| turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "capture_artifact", `{"action":"`+action+`"}`)) | ||
| } | ||
| turns = append(turns, textTurn("gave up")) | ||
|
|
||
| result, err := Run(context.Background(), "capture something", &mockProvider{turns: turns}, Options{ | ||
| Registry: registry, | ||
| PermissionMode: PermissionModeAsk, | ||
| MaxTurns: len(turns) + 5, | ||
| }) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if tool.ran != 0 { | ||
| t.Fatalf("SETUP INVALID: the tool executed %d times; it must be refused before Run", tool.ran) | ||
| } | ||
|
|
||
| refusals := 0 | ||
| for _, message := range result.Messages { | ||
| if strings.Contains(message.Content, "is disabled") { | ||
| refusals++ | ||
| } | ||
| } | ||
| if refusals > toolFailureStopAt { | ||
| t.Errorf("the run made %d refused calls; the six-call refusal halt never tripped because the streak re-keyed on each action's wording", refusals) | ||
| } | ||
|
|
||
| // And the halt has to read as a repeated refusal, not as varied errors. | ||
| stop := strings.ToLower(strings.Join(messageContents(result.Messages), "\n")) | ||
| if strings.Contains(stop, toolFailureHintMarker) { | ||
| t.Error("a refused, never-executed tool drew the retry hint") | ||
| } | ||
| } | ||
|
|
||
| func messageContents(messages []zeroruntime.Message) []string { | ||
| out := make([]string, 0, len(messages)) | ||
| for _, message := range messages { | ||
| out = append(out, message.Content) | ||
| } | ||
| return out | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package agent | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/localcontrol" | ||
| "github.com/Gitlawb/zero/internal/tools" | ||
| ) | ||
|
|
||
| // THE DISABLED-DRIVER BRANCH, REACHED FOR REAL. | ||
| // | ||
| // RejectBeforePermission refuses on two separate conditions, and its neighbour | ||
| // test constructs empty options, so it returns at the FIRST one (no artifact | ||
| // directory configured) and the second is never evaluated. Reverting the | ||
| // disabled-driver branch in local_capture.go to a plain errorResult therefore | ||
| // left the whole agent and tools suites green. | ||
| // | ||
| // This is the shape an operator actually produces: an artifact root IS | ||
| // configured and one driver IS enabled, and the model asks for an action owned | ||
| // by a different, disabled driver. Without provenance that call is read as a | ||
| // retriable failure, so it collects a schema hint and can spend the profile's | ||
| // failure-streak escalation, for a tool no argument change can enable. | ||
| func TestCaptureArtifactDisabledDriverIsAPolicyRefusal(t *testing.T) { | ||
| registry := tools.NewRegistry() | ||
| options := tools.LocalControlArtifactOptions{ | ||
| ArtifactsDir: t.TempDir(), | ||
| // Configured and enabled, so the tool as a whole is available and the | ||
| // missing-directory branch cannot fire. | ||
| Browser: localcontrol.BrowserOptions{Enabled: true, Driver: "test-browser", HelperPath: "browser-helper"}, | ||
| // Terminal deliberately left disabled. | ||
| } | ||
| for _, tool := range tools.NewLocalControlArtifactTools(options) { | ||
| registry.Register(tool) | ||
| } | ||
|
|
||
| // An enabled driver's action must still be accepted, or the test below would | ||
| // pass for a tool that refuses everything. | ||
| if result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{ | ||
| "action": "browser_screenshot", "name": "shot", | ||
| }, tools.RunOptions{PermissionGranted: true}); tools.IsPolicyRefusalResult(result) { | ||
| t.Fatalf("SETUP INVALID: the enabled browser driver was refused as policy: %s", result.Output) | ||
| } | ||
|
|
||
| result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{ | ||
| "action": "terminal_snapshot", "name": "snap", "session": "test-session", | ||
| }, tools.RunOptions{PermissionGranted: true}) | ||
|
|
||
| if result.Status != tools.StatusError { | ||
| t.Fatalf("SETUP INVALID: the disabled terminal driver did not refuse: %s / %s", result.Status, result.Output) | ||
| } | ||
| if !tools.IsPolicyRefusalResult(result) { | ||
| t.Fatalf("a driver disabled by configuration refuses without provenance, so it reads as a retriable failure: %#v", result.Meta) | ||
| } | ||
| if got := result.Meta["policy_refusal"]; got != tools.PolicyRefusalToolNotEnabled { | ||
| t.Errorf("policy_refusal = %q, want %q", got, tools.PolicyRefusalToolNotEnabled) | ||
| } | ||
| if isRetriableToolError(ToolResult{Status: result.Status, Output: result.ModelOutput(), Meta: result.Meta}) { | ||
| t.Error("the refusal is still classified as retriable, so the model gets a schema hint for a configuration decision") | ||
| } | ||
| } | ||
|
|
||
| // AND THE EARLY REJECTIONS MUST NOT COLLAPSE INTO ONE ANSWER. | ||
| // | ||
| // RejectBeforePermission refuses on configuration; argument validation refuses | ||
| // on a fixable mistake, and it runs FIRST, which is how the disabled-driver | ||
| // branch stayed uncovered. A malformed call must stay an ordinary retriable | ||
| // error, because trying again differently is exactly the right response to it. | ||
| func TestCaptureArtifactMalformedArgumentsStayRetriable(t *testing.T) { | ||
| registry := tools.NewRegistry() | ||
| for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{ | ||
| ArtifactsDir: t.TempDir(), | ||
| Terminal: localcontrol.TerminalOptions{Enabled: true, Driver: "test-terminal", HelperPath: "terminal-helper"}, | ||
| }) { | ||
| registry.Register(tool) | ||
| } | ||
|
|
||
| // The driver IS enabled, so nothing here is a configuration decision. The | ||
| // call is simply missing the session the action requires. | ||
| result := registry.RunWithOptions(context.Background(), "capture_artifact", map[string]any{ | ||
| "action": "terminal_snapshot", "name": "snap", | ||
| }, tools.RunOptions{PermissionGranted: true}) | ||
|
|
||
| if result.Status != tools.StatusError { | ||
| t.Fatalf("SETUP INVALID: the malformed call did not fail: %s", result.Output) | ||
| } | ||
| if tools.IsPolicyRefusalResult(result) { | ||
| t.Errorf("a fixable argument mistake was marked a policy refusal, so the model loses the schema hint that would fix it: %s", result.Output) | ||
| } | ||
| if !isRetriableToolError(ToolResult{Status: result.Status, Output: result.ModelOutput(), Meta: result.Meta}) { | ||
| t.Errorf("a fixable argument mistake is not retriable: %s", result.Output) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.