Require grounded Patrol investigations

This commit is contained in:
rcourtman 2026-08-15 11:48:51 +01:00
parent 73ce59a1ab
commit 0d0456f435
5 changed files with 175 additions and 5 deletions

View file

@ -62,6 +62,15 @@ prose. Unknown root cause does not by itself prohibit a reversible, bounded
initial proposal when evidence confirms the operational symptom; capability
validation, approval floors, tenant mode, emergency stop, dispatch, and
verification remain core-owned and fail closed.
An investigation cannot complete or submit a typed action proposal before the
model has made at least one structured call to an advertised evidence tool.
Until then, core withholds proposal authority while leaving evidence-tool
selection model-owned. If the provider returns prose without a tool call, core
must discard that prose as a durable conclusion and allow one bounded repair
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.
Model/tool orchestration is bounded by construction rather than by request
timeout. The legacy `Service` streaming and non-streaming paths share one

View file

@ -929,6 +929,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
patrolOutputLimitRecoveryAttempted := false
investigationOutputLimitRecoveryPending := false // A truncated investigation conclusion needs one evidence-only retry
investigationOutputLimitRecoveryAttempted := false
investigationEvidenceStartRepairPending := false // A tool-free investigation start gets one structured evidence retry
investigationEvidenceStartRepairAttempted := false
toolBlockedLastTurn := false // When true, request final text after budget/loop block
investigationProposalCompleted := false
acceptedFindingReports := 0
@ -970,7 +972,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
for turn < maxTurns ||
(patrolFindingRepairPending && !patrolFindingRepairAttempted) ||
(patrolOutputLimitRecoveryPending && !patrolOutputLimitRecoveryAttempted) ||
(investigationOutputLimitRecoveryPending && !investigationOutputLimitRecoveryAttempted) {
(investigationOutputLimitRecoveryPending && !investigationOutputLimitRecoveryAttempted) ||
(investigationEvidenceStartRepairPending && !investigationEvidenceStartRepairAttempted) {
// === CONTEXT COMPACTION: Compact old tool results to prevent context blowout ===
if turn > 0 {
compactOldToolResults(providerMessages, currentTurnStartIndex, compactionKeepTurns, compactionMinChars, a.knowledgeAccumulator)
@ -1056,6 +1059,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
patrolFindingContinuationTurn := false
patrolOutputLimitRecoveryTurn := false
investigationOutputLimitRecoveryTurn := false
investigationEvidenceStartRepairTurn := false
if patrolFindingRepairPending && !patrolFindingRepairAttempted {
if applyPatrolFindingLifecycleRepairRequest(&req, a.currentExecutionProfile(), tools) {
patrolFindingRepairTurn = true
@ -1104,6 +1108,18 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
Str("session_id", sessionID).
Msg("[AgenticLoop] Investigation output limit reached — retrying one evidence-only conclusion turn")
}
if !patrolFindingRepairTurn && !patrolOutputLimitRecoveryTurn && !investigationOutputLimitRecoveryTurn && investigationEvidenceStartRepairPending && !investigationEvidenceStartRepairAttempted {
if !applyInvestigationEvidenceStartRepairRequest(&req, a.currentExecutionProfile(), tools) {
return resultMessages, fmt.Errorf("Patrol investigation requires grounded evidence, but no evidence tools are available")
}
investigationEvidenceStartRepairTurn = true
investigationEvidenceStartRepairPending = false
investigationEvidenceStartRepairAttempted = true
log.Warn().
Int("turn", turn).
Str("session_id", sessionID).
Msg("[AgenticLoop] Investigation returned prose before evidence — requiring one structured evidence call")
}
if !patrolFindingRepairTurn && !patrolOutputLimitRecoveryTurn && shouldOfferPatrolFindingLifecycleContinuation(patrolFindingContinuationPending, writeCompletedLastTurn, toolBlockedLastTurn) {
if applyPatrolFindingLifecycleContinuationRequest(&req, a.currentExecutionProfile(), tools) {
patrolFindingContinuationTurn = true
@ -1116,7 +1132,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
patrolFindingContinuationPending = false
}
}
if !patrolFindingRepairTurn && !patrolOutputLimitRecoveryTurn && !patrolFindingContinuationTurn && shouldOfferFinalPatrolFindingDecision(turn, maxTurns, patrolFindingSummaryPending, writeCompletedLastTurn, toolBlockedLastTurn) {
if !patrolFindingRepairTurn && !patrolOutputLimitRecoveryTurn && !patrolFindingContinuationTurn && !investigationEvidenceStartRepairTurn && shouldOfferFinalPatrolFindingDecision(turn, maxTurns, patrolFindingSummaryPending, writeCompletedLastTurn, toolBlockedLastTurn) {
// Watch detection gives the model one final, tightly scoped chance to
// persist the conclusion it reached from earlier evidence. Other
// profiles keep the historical tool-free final response.
@ -1165,7 +1181,7 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
Str("session_id", sessionID).
Msg("[AgenticLoop] Tool calls blocked last turn — omitting tools for final response")
}
if isPatrolInvestigationExecution(a.currentExecutionProfile()) && !investigationOutputLimitRecoveryTurn {
if isPatrolInvestigationExecution(a.currentExecutionProfile()) && !investigationOutputLimitRecoveryTurn && !investigationEvidenceStartRepairTurn {
switch {
case investigationProposalCompleted:
req.Tools = nil
@ -1175,6 +1191,10 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
req.Tools = investigationTerminalTools(tools)
textOnlySafetyBrake = len(req.Tools) == 0
req.System += investigationEvidenceBudgetExhaustedSystemPrompt
case a.totalEvidenceCalls == 0:
// A proposal cannot be grounded before any evidence capability has
// actually been invoked. Keep the model free to choose the best read.
req.Tools = investigationEvidenceTools(tools)
}
}
applyExecutionInferenceAllowance(&req, a.currentExecutionProfile(), patrolSummaryOnlyTurn, a.totalOutputTokens)
@ -1678,6 +1698,21 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me
continue
}
if isPatrolInvestigationExecution(a.currentExecutionProfile()) && a.totalEvidenceCalls == 0 {
// Prose about intended tool use is not evidence. Preserve it only in
// provider context for one bounded correction turn, never as the
// durable investigation conclusion.
if len(resultMessages) > 0 {
resultMessages[len(resultMessages)-1].Content = ""
}
if investigationEvidenceStartRepairAttempted {
return resultMessages, fmt.Errorf("Patrol investigation completed twice without a structured evidence tool call")
}
investigationEvidenceStartRepairPending = true
turn++
continue
}
// === FSM ENFORCEMENT GATE 2: Check if final answer is allowed ===
a.mu.Lock()
fsm := a.sessionFSM

View file

@ -136,6 +136,102 @@ func TestInvestigationEvidenceBudgetHelpers(t *testing.T) {
if got := investigationEvidenceCheckpoint(15); got != 8 {
t.Fatalf("checkpoint(15) = %d, want 8", got)
}
evidenceOnly := investigationEvidenceTools(available)
if len(evidenceOnly) != 2 || evidenceOnly[0].Name != agentcapabilities.PulseQueryToolName || evidenceOnly[1].Name != agentcapabilities.PatrolActionCapabilitiesToolName {
t.Fatalf("evidence tools = %+v, want proposal excluded", evidenceOnly)
}
}
func TestAgenticLoopPatrolInvestigationRepairsToolFreeStart(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: "content", Data: providers.ContentEvent{Text: "I will now call a made-up tool in prose."}})
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{}})
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.SetMaxTurns(3)
available := []providers.Tool{
{Name: agentcapabilities.PulseQueryToolName},
{Name: agentcapabilities.PatrolActionCapabilitiesToolName},
{Name: agentcapabilities.PatrolProposeActionToolName},
}
result, err := loop.ExecuteWithTools(context.Background(), "grounding-repair", []Message{{Role: "user", Content: "investigate"}}, available, func(StreamEvent) {})
if err != nil {
t.Fatalf("ExecuteWithTools: %v", err)
}
if len(requests) != 3 {
t.Fatalf("provider requests = %d, want 3", len(requests))
}
for _, requestIndex := range []int{0, 1} {
for _, tool := range requests[requestIndex].Tools {
if tool.Name == agentcapabilities.PatrolProposeActionToolName {
t.Fatalf("request %d offered an ungrounded proposal: %+v", requestIndex, requests[requestIndex].Tools)
}
}
}
if requests[1].ToolChoice == nil || requests[1].ToolChoice.Type != providers.ToolChoiceRequired {
t.Fatalf("repair tool choice = %+v, want required", requests[1].ToolChoice)
}
if !strings.Contains(requests[1].System, "cannot accept a completed investigation") {
t.Fatalf("repair system prompt missing grounding contract: %q", requests[1].System)
}
if loop.GetTotalEvidenceCalls() != 1 {
t.Fatalf("evidence calls = %d, want 1", loop.GetTotalEvidenceCalls())
}
if len(result) == 0 || result[0].Content != "" || result[len(result)-1].Content != "grounded conclusion" {
t.Fatalf("durable result retained ungrounded prose or lost conclusion: %+v", result)
}
}
func TestAgenticLoopPatrolInvestigationFailsClosedAfterToolFreeRepair(t *testing.T) {
provider := &stubStreamingProvider{}
var requests []providers.ChatRequest
provider.chatStream = func(_ context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
requests = append(requests, req)
callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "I would inspect this later."}})
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.SetMaxTurns(1)
result, err := loop.ExecuteWithTools(context.Background(), "grounding-fail-closed", []Message{{Role: "user", Content: "investigate"}}, []providers.Tool{{Name: agentcapabilities.PulseQueryToolName}}, func(StreamEvent) {})
if err == nil || !strings.Contains(err.Error(), "completed twice without a structured evidence tool call") {
t.Fatalf("ExecuteWithTools error = %v, want fail-closed grounding error", err)
}
if len(requests) != 2 {
t.Fatalf("provider requests = %d, want one bounded repair", len(requests))
}
if requests[1].ToolChoice == nil || requests[1].ToolChoice.Type != providers.ToolChoiceRequired {
t.Fatalf("repair tool choice = %+v, want required", requests[1].ToolChoice)
}
for _, msg := range result {
if strings.TrimSpace(msg.Content) != "" {
t.Fatalf("ungrounded prose escaped into durable result: %+v", result)
}
}
}
func TestAgenticLoopPatrolInvestigationEnforcesEvidenceBudget(t *testing.T) {
@ -2095,9 +2191,9 @@ func TestAgenticLoop_DoesNotRetryAfterPartialEvents(t *testing.T) {
func TestAgenticLoop_RetriesIncompleteNonInteractiveStreamAfterPartialEvent(t *testing.T) {
provider := &stubStreamingProvider{}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
executor.ApplyExecutionProfile(tools.ProfilePatrolInvestigation)
executor.ApplyExecutionProfile(tools.ProfilePatrolDetection)
loop := NewAgenticLoop(provider, executor, "investigation prompt")
loop.SetExecutionProfile(tools.ProfilePatrolInvestigation)
loop.SetExecutionProfile(tools.ProfilePatrolDetection)
callCount := 0
provider.chatStream = func(_ context.Context, _ providers.ChatRequest, callback providers.StreamCallback) error {
@ -2138,6 +2234,7 @@ func TestAgenticLoop_RecoversTruncatedInvestigationConclusionFromExistingEvidenc
loop := NewAgenticLoop(provider, executor, "full investigation prompt")
loop.SetExecutionProfile(tools.ProfilePatrolInvestigation)
loop.SetMaxTurns(1)
loop.totalEvidenceCalls = 1
providerCalls := 0
var recoveryRequest providers.ChatRequest

View file

@ -18,6 +18,10 @@ const investigationProposalCompletedSystemPrompt = `
INVESTIGATION COMPLETION: A typed action proposal has already been accepted for this run. Do not call more tools. Produce the required investigation summary from the evidence already collected and state that the proposal is pending governed policy or operator handling; never claim that it executed.`
const investigationEvidenceStartRepairSystemPrompt = `
INVESTIGATION GROUNDING: Pulse cannot accept a completed investigation until you make at least one structured call to an advertised evidence tool. Choose the most relevant available tool and arguments from the exact finding and resource identity already supplied. Call the tool now through the structured tool interface. Do not narrate, simulate, or place a hypothetical tool call in prose.`
const investigationEvidenceBudgetExhaustedSystemPrompt = `
INVESTIGATION COMPLETION: The evidence-call budget is exhausted. No more evidence tools are available. Use the evidence already collected. If it supports a safe advertised remediation and no proposal has been submitted, you must call patrol_propose_action once before the final summary; never leave that remediation only as prose. Otherwise produce the required final summary and state any remaining uncertainty.`
@ -42,6 +46,30 @@ func isInvestigationEvidenceTool(name string) bool {
return strings.TrimSpace(name) != agentcapabilities.PatrolProposeActionToolName
}
func investigationEvidenceTools(available []providers.Tool) []providers.Tool {
filtered := make([]providers.Tool, 0, len(available))
for _, tool := range available {
if isInvestigationEvidenceTool(tool.Name) {
filtered = append(filtered, tool)
}
}
return filtered
}
func applyInvestigationEvidenceStartRepairRequest(req *providers.ChatRequest, profile aitools.ExecutionProfile, available []providers.Tool) bool {
if req == nil || !isPatrolInvestigationExecution(profile) {
return false
}
evidenceTools := investigationEvidenceTools(available)
if len(evidenceTools) == 0 {
return false
}
req.Tools = evidenceTools
req.ToolChoice = &providers.ToolChoice{Type: providers.ToolChoiceRequired}
req.System += investigationEvidenceStartRepairSystemPrompt
return true
}
func investigationTerminalTools(available []providers.Tool) []providers.Tool {
for _, tool := range available {
if tool.Name == agentcapabilities.PatrolProposeActionToolName {

View file

@ -1205,6 +1205,7 @@ func TestInvestigationLoopRedactsProposalParamsEverywhereDurable(t *testing.T) {
loop := NewAgenticLoop(provider, exec, "base prompt")
loop.SetExecutionProfile(tools.ProfilePatrolInvestigation)
loop.totalEvidenceCalls = 1
var streamedRawParam bool
messages, err := loop.ExecuteWithTools(