mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Reject unadvertised Patrol tool calls
This commit is contained in:
parent
0d0456f435
commit
2a15ccbefa
5 changed files with 117 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue