mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-17 21:13:33 +00:00
Require grounded Patrol investigations
This commit is contained in:
parent
73ce59a1ab
commit
0d0456f435
5 changed files with 175 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue