Reject unadvertised Patrol tool calls

This commit is contained in:
rcourtman 2026-08-15 12:15:17 +01:00
parent 0d0456f435
commit 2a15ccbefa
5 changed files with 117 additions and 3 deletions

View file

@ -71,6 +71,13 @@ turn that requires a structured call from the same advertised evidence
manifest. The repair is generic and carries no platform-, incident-, or
model-specific diagnosis. A second tool-free response fails the investigation
closed instead of recording narrated or simulated tool use as completed work.
Provider-returned function names must also match the exact tool manifest sent
on that turn before read/write classification or execution. A name invented
from an action or operation enum is rejected as `TOOL_NOT_ADVERTISED`, receives
the exact advertised-name set for one model-owned correction, does not consume
the investigation evidence budget, and must never be misreported as an FSM
mutation refusal. Unknown functions remain non-executable and are never
heuristically mapped onto a capability.
Model/tool orchestration is bounded by construction rather than by request
timeout. The legacy `Service` streaming and non-streaming paths share one

View file

@ -524,6 +524,26 @@ func withoutProviderTool(providerTools []providers.Tool, toolName string) []prov
return filtered
}
func advertisedProviderToolNames(providerTools []providers.Tool) []string {
names := make([]string, 0, len(providerTools))
for _, tool := range providerTools {
if name := strings.TrimSpace(tool.Name); name != "" {
names = append(names, name)
}
}
return names
}
func providerToolIsAdvertised(providerTools []providers.Tool, toolName string) bool {
toolName = strings.TrimSpace(toolName)
for _, tool := range providerTools {
if strings.TrimSpace(tool.Name) == toolName {
return true
}
}
return false
}
// applyPatrolFinalFindingDecisionRequest preserves one final model-owned
// decision opportunity for Watch runs that used their earlier turns gathering
// evidence. It deliberately narrows the provider projection to the two
@ -2067,6 +2087,30 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
Str("id", tc.ID).
Msg("Pre-checking tool call")
// Validate against the exact manifest sent on this turn before any
// safety classification. Some local providers accept a function name
// invented from an action/operation enum. Treating that unknown name as
// a write produces a misleading FSM refusal and can make the model chase
// a mutation path that never existed.
if a.currentExecutionProfile().NonInteractive() && !providerToolIsAdvertised(req.Tools, tc.Name) {
availableNames := advertisedProviderToolNames(req.Tools)
unknownMsg := fmt.Sprintf(
"TOOL_NOT_ADVERTISED: %q was not offered on this turn. Tool function names are exact. Choose one of: %s. Put action or operation values inside that tool's arguments; do not use them as function names.",
strings.TrimSpace(tc.Name), strings.Join(availableNames, ", "),
)
log.Warn().
Str("tool", tc.Name).
Strs("advertised_tools", availableNames).
Str("session_id", sessionID).
Msg("[AgenticLoop] Rejected provider tool name outside exact turn manifest")
emitToolStartIfNeeded(tc)
emitToolEndEvent(callback, tc.ID, tc.Name, tc.Input, unknownMsg, false)
projection := newProviderToolResultContextProjection(tc.ID, unknownMsg, true)
resultMessages = append(resultMessages, Message{ID: uuid.New().String(), Role: "user", Timestamp: time.Now(), ToolResult: &projection.Transcript})
providerMessages = append(providerMessages, providers.Message{Role: "user", ToolResult: &projection.Model})
continue
}
// === FSM ENFORCEMENT GATE 1: Check if tool is allowed in current state ===
toolKind := ClassifyToolCall(tc.Name, tc.Input)

View file

@ -234,6 +234,65 @@ func TestAgenticLoopPatrolInvestigationFailsClosedAfterToolFreeRepair(t *testing
}
}
func TestAgenticLoopPatrolInvestigationRejectsUnadvertisedToolBeforeFSM(t *testing.T) {
provider := &stubStreamingProvider{}
var requests []providers.ChatRequest
turn := 0
provider.chatStream = func(_ context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
requests = append(requests, req)
turn++
switch turn {
case 1:
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ToolCalls: []providers.ToolCall{{ID: "invented", Name: "container_state", Input: map[string]interface{}{"resource_id": "container-1"}}}}})
case 2:
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{ToolCalls: []providers.ToolCall{{ID: "evidence", Name: agentcapabilities.PulseQueryToolName, Input: map[string]interface{}{"query": "containers"}}}}})
default:
callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "grounded conclusion"}})
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
}
return nil
}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
executor.ApplyExecutionProfile(tools.ProfilePatrolInvestigation)
loop := NewAgenticLoop(provider, executor, "system")
loop.SetExecutionProfile(tools.ProfilePatrolInvestigation)
loop.SetSessionFSM(NewSessionFSM())
loop.SetMaxTurns(4)
var rejected ToolEndData
result, err := loop.ExecuteWithTools(
context.Background(),
"unknown-tool-repair",
[]Message{{Role: "user", Content: "investigate"}},
[]providers.Tool{{Name: agentcapabilities.PulseQueryToolName}, {Name: agentcapabilities.PatrolProposeActionToolName}},
func(event StreamEvent) {
if event.Type != "tool_end" {
return
}
var candidate ToolEndData
if json.Unmarshal(event.Data, &candidate) == nil && candidate.ID == "invented" {
rejected = candidate
}
},
)
if err != nil {
t.Fatalf("ExecuteWithTools: %v", err)
}
if rejected.Success || !strings.Contains(rejected.Output, "TOOL_NOT_ADVERTISED") || strings.Contains(rejected.Output, "FSM blocked") {
t.Fatalf("unknown tool result = %+v, want exact-manifest rejection before FSM", rejected)
}
if !strings.Contains(rejected.Output, agentcapabilities.PulseQueryToolName) || strings.Contains(rejected.Output, agentcapabilities.PatrolProposeActionToolName) {
t.Fatalf("unknown tool correction did not use exact turn manifest: %q", rejected.Output)
}
if loop.GetTotalEvidenceCalls() != 1 {
t.Fatalf("evidence calls = %d, want only the advertised call counted", loop.GetTotalEvidenceCalls())
}
if len(requests) != 3 || len(result) == 0 || result[len(result)-1].Content != "grounded conclusion" {
t.Fatalf("repair did not reach grounded conclusion: requests=%d result=%+v", len(requests), result)
}
}
func TestAgenticLoopPatrolInvestigationEnforcesEvidenceBudget(t *testing.T) {
provider := &stubStreamingProvider{}
var requests []providers.ChatRequest

View file

@ -32,8 +32,10 @@ state-changing action; such calls will be blocked.`
EXECUTION MODE: Patrol investigation
This is a non-interactive investigation of one finding. You cannot ask the user questions or
directly change infrastructure. Gather evidence with read-only tools and conclude with your
diagnosis. If your diagnosis concludes that an advertised remediation is safe and supported by
the evidence, call patrol_propose_action before the final summary; that governed proposal is not
diagnosis. Tool function names are exact: call only a top-level name from the advertised tool
manifest. Values inside a tool's action or operation schema are arguments to that tool, never
function names of their own. If your diagnosis concludes that an advertised remediation is safe
and supported by the evidence, call patrol_propose_action before the final summary; that governed proposal is not
execution or approval. An unknown root cause does not by itself rule out a reversible, advertised
initial remediation when the evidence confirms the operational symptom and supports a bounded
reason for trying it. If your Recommendation or Conclusion tells the operator to try, consider,

View file

@ -1023,7 +1023,9 @@ func TestExecutionProfilePromptModes(t *testing.T) {
if !strings.Contains(prompt, "call patrol_propose_action") ||
!strings.Contains(prompt, "you must call patrol_propose_action for that exact action") ||
!strings.Contains(prompt, "Core policy independently decides whether it may execute") ||
!strings.Contains(prompt, "unknown root cause does not by itself rule out") {
!strings.Contains(prompt, "unknown root cause does not by itself rule out") ||
!strings.Contains(prompt, "Tool function names are exact") ||
!strings.Contains(prompt, "action or operation schema are arguments") {
t.Fatalf("investigation prompt must turn supported remediation into a governed proposal, got %q", prompt)
}
}