Preserve Patrol investigation evidence continuity

This commit is contained in:
rcourtman
2026-07-15 15:43:18 +01:00
parent 9e3765e19d
commit d307fe8869
6 changed files with 393 additions and 5 deletions
@@ -3118,6 +3118,20 @@ Qualification floor: Patrol model launch and product-claim qualification must us
container ID or substitute a display name after `pulse_query` returns a
canonical `app-container` ID; cross-tool identity translation is an AI
runtime responsibility.
Investigation action-catalog and proposal tools inherit that identity
boundary in both directions: an exact, uniquely resolved Docker provider
coordinate may be translated back to its canonical `app-container` target,
and the catalog response plus captured proposal must carry that canonical
target. Unknown or ambiguous references still fail closed. The model is not
responsible for Pulse's provider-to-canonical identity plumbing.
Agentic context compaction is also evidence-preserving rather than merely
size-reducing. When a read tool returns structured safety-relevant state,
its deterministic knowledge projection must retain the bounded fields needed
for later diagnosis and action choice while excluding labels, environment
values, and other unrelated raw inspection content. Docker inspection
compaction therefore retains container state, running/OOM/dead flags, exit
and health results, restart count/policy, and image identity; it must not
collapse a valid inspection to punctuation or the first formatting lines.
Patrol autonomous-loop floor: Watch must preserve model-owned investigation
while requiring an accepted structured outcome for every active finding the
+19
View File
@@ -356,6 +356,25 @@ func TestBuildCompactSummary_WithKAFacts(t *testing.T) {
assert.NotContains(t, result, "already been processed", "should use KA format, not generic format")
}
func TestBuildCompactSummary_PreservesDockerInspectState(t *testing.T) {
toolInput := map[string]interface{}{
"command": "docker inspect patrol-worker",
"target_host": "lab-host",
}
content := `[{"State":{"Status":"exited","Running":false,"Paused":false,"Restarting":false,"OOMKilled":false,"Dead":false,"ExitCode":137,"Error":"","Health":{"Status":"unhealthy","Log":[{"ExitCode":0}]}},"RestartCount":0,"HostConfig":{"RestartPolicy":{"Name":"no"}},"Config":{"Image":"alpine:3.20"}}]`
ka := NewKnowledgeAccumulator()
for _, fact := range ExtractFacts("pulse_read", toolInput, content) {
ka.AddFactForTool("inspect-call", fact.Category, fact.Key, fact.Value)
}
result := buildCompactSummary("pulse_read", toolInput, content, ka, "inspect-call")
assert.Contains(t, result, "Key facts:")
assert.Contains(t, result, "status=exited")
assert.Contains(t, result, "exit=137")
assert.Contains(t, result, "oom=false")
assert.Contains(t, result, "restart_policy=no")
}
func TestBuildCompactSummary_WithKAFacts_NoFacts(t *testing.T) {
ka := NewKnowledgeAccumulator()
// No facts added for this tool ID — should fall back to generic format
+182 -3
View File
@@ -847,19 +847,33 @@ func extractExecFacts(input map[string]interface{}, resultText string) []FactEnt
Error string `json:"error"`
}
var value string
output := resultText
parsedCommandResponse := false
commandExitCode := 0
if err := json.Unmarshal([]byte(resultText), &cmdResp); err == nil && (cmdResp.Output != "" || cmdResp.Stdout != "" || cmdResp.Error != "") {
output := cmdResp.Output
output = cmdResp.Output
if output == "" {
output = cmdResp.Stdout
}
if output == "" {
output = cmdResp.Error
}
parsedCommandResponse = true
commandExitCode = cmdResp.ExitCode
}
var value string
if summary, ok := extractDockerInspectSummary(strFromMap(input, "command"), output); ok {
if parsedCommandResponse {
value = fmt.Sprintf("exit=%d, %s", commandExitCode, summary)
} else {
value = summary
}
} else if parsedCommandResponse {
// Take first 2 lines
lines := strings.SplitN(output, "\n", 3)
summary := strings.Join(lines[:min(2, len(lines))], "; ")
value = fmt.Sprintf("exit=%d, %s", cmdResp.ExitCode, summary)
value = fmt.Sprintf("exit=%d, %s", commandExitCode, summary)
} else {
// Fallback: use first 2 lines of raw result text
lines := strings.SplitN(resultText, "\n", 3)
@@ -874,6 +888,171 @@ func extractExecFacts(input map[string]interface{}, resultText string) []FactEnt
}}
}
type dockerInspectState struct {
Status string `json:"Status"`
Running bool `json:"Running"`
Paused bool `json:"Paused"`
Restarting bool `json:"Restarting"`
OOMKilled bool `json:"OOMKilled"`
Dead bool `json:"Dead"`
ExitCode int `json:"ExitCode"`
Error string `json:"Error"`
Health *struct {
Status string `json:"Status"`
Log []struct {
ExitCode int `json:"ExitCode"`
} `json:"Log"`
} `json:"Health"`
}
type dockerInspectObject struct {
State *dockerInspectState `json:"State"`
RestartCount *int `json:"RestartCount"`
HostConfig *struct {
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
} `json:"HostConfig"`
Config *struct {
Image string `json:"Image"`
} `json:"Config"`
}
type dockerInspectSummary struct {
state *dockerInspectState
restartCount *int
restartPolicy string
image string
}
func extractDockerInspectSummary(command, output string) (string, bool) {
if !isDockerInspectCommand(command) {
return "", false
}
output = strings.TrimSpace(output)
if output == "" {
return "", false
}
var summary dockerInspectSummary
var objects []json.RawMessage
if err := json.Unmarshal([]byte(output), &objects); err != nil {
decoder := json.NewDecoder(strings.NewReader(output))
for {
var raw json.RawMessage
if err := decoder.Decode(&raw); err != nil {
break
}
objects = append(objects, raw)
}
}
for _, raw := range objects {
mergeDockerInspectObject(&summary, raw)
}
if summary.state == nil {
return "", false
}
state := summary.state
parts := []string{
"status=" + strings.TrimSpace(state.Status),
fmt.Sprintf("running=%t", state.Running),
fmt.Sprintf("paused=%t", state.Paused),
fmt.Sprintf("restarting=%t", state.Restarting),
fmt.Sprintf("oom=%t", state.OOMKilled),
fmt.Sprintf("dead=%t", state.Dead),
fmt.Sprintf("exit=%d", state.ExitCode),
}
if strings.TrimSpace(state.Error) == "" {
parts = append(parts, "error=none")
} else {
parts = append(parts, "error=present")
}
if state.Health != nil {
if health := strings.TrimSpace(state.Health.Status); health != "" {
parts = append(parts, "health="+health)
}
if logs := state.Health.Log; len(logs) > 0 {
parts = append(parts, fmt.Sprintf("last_health_exit=%d", logs[len(logs)-1].ExitCode))
}
}
if summary.restartCount != nil {
parts = append(parts, fmt.Sprintf("restarts=%d", *summary.restartCount))
}
if summary.restartPolicy != "" {
parts = append(parts, "restart_policy="+summary.restartPolicy)
}
if summary.image != "" {
parts = append(parts, "image="+truncateDockerInspectToken(summary.image, 48))
}
return truncateValue(strings.Join(parts, " ")), true
}
func isDockerInspectCommand(command string) bool {
fields := strings.Fields(strings.TrimSpace(command))
if len(fields) < 2 {
return false
}
if fields[0] == "sudo" {
fields = fields[1:]
}
return len(fields) >= 2 && fields[0] == "docker" && fields[1] == "inspect"
}
func mergeDockerInspectObject(summary *dockerInspectSummary, raw json.RawMessage) {
if summary == nil || len(raw) == 0 {
return
}
var object dockerInspectObject
if err := json.Unmarshal(raw, &object); err == nil && object.State != nil {
summary.state = object.State
summary.restartCount = object.RestartCount
if object.HostConfig != nil {
summary.restartPolicy = strings.TrimSpace(object.HostConfig.RestartPolicy.Name)
}
if object.Config != nil {
summary.image = strings.TrimSpace(object.Config.Image)
}
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return
}
if _, hasStatus := fields["Status"]; hasStatus {
var state dockerInspectState
if err := json.Unmarshal(raw, &state); err == nil {
summary.state = &state
}
return
}
if nameRaw, hasName := fields["Name"]; hasName {
var name string
if err := json.Unmarshal(nameRaw, &name); err == nil && isDockerRestartPolicy(name) {
summary.restartPolicy = strings.TrimSpace(name)
}
}
}
func isDockerRestartPolicy(value string) bool {
switch strings.TrimSpace(value) {
case "no", "always", "unless-stopped", "on-failure":
return true
default:
return false
}
}
func truncateDockerInspectToken(value string, maxLen int) string {
value = strings.Join(strings.Fields(value), "_")
if len(value) > maxLen {
return value[:maxLen]
}
return value
}
// --- pulse_metrics ---
func extractMetricsFacts(input map[string]interface{}, resultText string) []FactEntry {
@@ -245,6 +245,55 @@ func TestExtractFacts_Exec_FallbackRaw(t *testing.T) {
assert.Contains(t, facts[0].Value, "not json at all")
}
func TestExtractFacts_Exec_DockerInspectPreservesCriticalState(t *testing.T) {
input := map[string]interface{}{
"command": "docker inspect patrol-worker",
"target_host": "lab-host",
}
result := `[{"State":{"Status":"exited","Running":false,"Paused":false,"Restarting":false,"OOMKilled":false,"Dead":false,"ExitCode":137,"Error":"","Health":{"Status":"unhealthy","Log":[{"ExitCode":0,"Output":""}]}},"RestartCount":0,"HostConfig":{"RestartPolicy":{"Name":"no"}},"Config":{"Image":"alpine:3.20","Env":["TOKEN=must-not-survive"],"Labels":{"secret":"must-not-survive"}}}]`
facts := ExtractFacts("pulse_read", input, result)
require.Len(t, facts, 1)
value := facts[0].Value
for _, expected := range []string{
"status=exited", "running=false", "oom=false", "exit=137",
"error=none", "health=unhealthy", "last_health_exit=0",
"restarts=0", "restart_policy=no", "image=alpine:3.20",
} {
assert.Contains(t, value, expected)
}
assert.NotContains(t, value, "TOKEN")
assert.NotContains(t, value, "secret")
assert.LessOrEqual(t, len(value), maxValueLen)
}
func TestExtractFacts_Exec_DockerInspectFormattedState(t *testing.T) {
input := map[string]interface{}{
"command": "docker inspect patrol-worker --format '{{json .State}} {{json .HostConfig.RestartPolicy}}'",
"target_host": "lab-host",
}
result := `{"Status":"exited","Running":false,"Paused":false,"Restarting":false,"OOMKilled":false,"Dead":false,"ExitCode":137,"Error":"","Health":{"Status":"unhealthy","Log":[{"ExitCode":0}]}} {"Name":"no","MaximumRetryCount":0}`
facts := ExtractFacts("pulse_read", input, result)
require.Len(t, facts, 1)
assert.Contains(t, facts[0].Value, "status=exited")
assert.Contains(t, facts[0].Value, "last_health_exit=0")
assert.Contains(t, facts[0].Value, "restart_policy=no")
}
func TestExtractFacts_Exec_NonInspectJSONKeepsGenericSummary(t *testing.T) {
input := map[string]interface{}{
"command": "docker ps --format '{{json .}}'",
"target_host": "lab-host",
}
result := `{"Status":"exited","Running":false,"OOMKilled":false,"ExitCode":137}`
facts := ExtractFacts("pulse_read", input, result)
require.Len(t, facts, 1)
assert.NotContains(t, facts[0].Value, "status=exited")
assert.Contains(t, facts[0].Value, `"Status":"exited"`)
}
func TestExtractFacts_Metrics(t *testing.T) {
input := map[string]interface{}{"action": "performance", "resource_id": "vm101"}
// Actual format: summary is map[string]ResourceMetricsSummary keyed by resource ID
@@ -230,6 +230,81 @@ func TestActionCapabilitiesCanFollowInvestigationToCausalResource(t *testing.T)
assert.Zero(t, failed, "catalog reads must not consume proposal cardinality or count as failed proposals")
}
func TestActionCapabilitiesCanonicalizeResolvedDockerCoordinate(t *testing.T) {
const (
canonicalID = "app-container-abc123"
containerID = "92847aa6ab18fef9fc6e619f5b8350948"
agentID = "agent-f4f64c6cc2cc062e"
)
catalog := func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) {
if resourceID != canonicalID {
return nil, errors.New("resource not found")
}
return []unified.ResourceCapability{{Name: "start"}}, nil
}
provider := &stubUnifiedResourceProvider{resources: []unified.Resource{{
ID: canonicalID,
Type: unified.ResourceTypeAppContainer,
Docker: &unified.DockerData{
AgentID: agentID,
ContainerID: containerID,
},
}}}
capture := NewProposalCapture(ProposalIdentity{}, catalog)
exec := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: provider})
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
exec.SetProposalCapture(capture)
rawCoordinate := "docker:" + agentID + ":" + containerID
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
ID: "catalog-docker",
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
Arguments: map[string]interface{}{"resource_id": rawCoordinate},
})
require.NoError(t, err)
require.NotEmpty(t, result.Content)
assert.Contains(t, result.Content[0].Text, `"resource_id":"`+canonicalID+`"`)
assert.Contains(t, result.Content[0].Text, `"name":"start"`)
proposalResult := executePropose(t, exec, "proposal-docker", map[string]interface{}{
"resource_id": rawCoordinate,
"capability_name": "start",
"reason": "restore the stopped worker",
})
assert.Contains(t, proposalResult.Content[0].Text, canonicalID)
proposal, failed, outcomeErr := capture.Outcome()
require.NoError(t, outcomeErr)
require.NotNil(t, proposal)
assert.Zero(t, failed)
assert.Equal(t, canonicalID, proposal.ResourceID)
}
func TestActionCapabilitiesDoNotCanonicalizeAmbiguousContainerID(t *testing.T) {
const containerID = "shared-container-id"
provider := &stubUnifiedResourceProvider{resources: []unified.Resource{
{ID: "app-container-a", Type: unified.ResourceTypeAppContainer, Docker: &unified.DockerData{ContainerID: containerID, AgentID: "agent-a"}},
{ID: "app-container-b", Type: unified.ResourceTypeAppContainer, Docker: &unified.DockerData{ContainerID: containerID, AgentID: "agent-b"}},
}}
capture := NewProposalCapture(ProposalIdentity{}, func(_ context.Context, resourceID string) ([]unified.ResourceCapability, error) {
if resourceID != containerID {
t.Fatalf("ambiguous reference was rewritten to %q", resourceID)
}
return nil, errors.New("ambiguous resource")
})
exec := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: provider})
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
exec.SetProposalCapture(capture)
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
ID: "catalog-ambiguous",
Name: agentcapabilities.PatrolActionCapabilitiesToolName,
Arguments: map[string]interface{}{"resource_id": containerID},
})
require.NoError(t, err)
require.NotEmpty(t, result.Content)
assert.Contains(t, result.Content[0].Text, "capability catalog lookup failed")
}
func TestActionCapabilitiesRequiresInvestigationCatalog(t *testing.T) {
exec := newInvestigationExecutor(t, nil)
result, err := exec.ExecuteInvocation(context.Background(), ToolInvocation{
+54 -2
View File
@@ -112,7 +112,7 @@ func (e *PulseToolExecutor) executeActionCapabilities(ctx context.Context, args
if e.proposalCapture == nil {
return NewErrorResult(fmt.Errorf("action capabilities are not available in this run")), nil
}
resourceID := unified.CanonicalResourceID(stringArg(args, "resource_id"))
resourceID := e.canonicalProposalResourceID(stringArg(args, "resource_id"))
capabilities, err := e.proposalCapture.Capabilities(ctx, resourceID)
if err != nil {
return NewErrorResult(fmt.Errorf("capability catalog lookup failed for resource %q", resourceID)), nil
@@ -150,7 +150,7 @@ func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[s
return NewErrorResult(fmt.Errorf("action proposals are not available in this run")), nil
}
resourceID := unified.CanonicalResourceID(stringArg(args, "resource_id"))
resourceID := e.canonicalProposalResourceID(stringArg(args, "resource_id"))
capabilityName := strings.TrimSpace(stringArg(args, "capability_name"))
reason := strings.TrimSpace(stringArg(args, "reason"))
params, _ := args["params"].(map[string]interface{})
@@ -175,6 +175,58 @@ func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[s
capabilityName, resourceID)), nil
}
// canonicalProposalResourceID keeps provider coordinates discovered during an
// investigation from leaking into the governed action lifecycle. Only an exact,
// unique app-container match is translated; ambiguous or unknown references
// continue to the catalog unchanged and fail closed there.
func (e *PulseToolExecutor) canonicalProposalResourceID(reference string) string {
reference = unified.CanonicalResourceID(reference)
if reference == "" || e.unifiedResourceProvider == nil {
return reference
}
resolvedID := ""
for _, resource := range e.unifiedResourceProvider.GetByType(unified.ResourceTypeAppContainer) {
if !matchesAppContainerActionReference(resource, reference) {
continue
}
candidateID := unified.CanonicalResourceID(resource.ID)
if candidateID == "" {
continue
}
if resolvedID != "" && resolvedID != candidateID {
return reference
}
resolvedID = candidateID
}
if resolvedID != "" {
return resolvedID
}
return reference
}
func matchesAppContainerActionReference(resource unified.Resource, reference string) bool {
reference = strings.TrimSpace(reference)
if reference == "" {
return false
}
canonicalID := unified.CanonicalResourceID(resource.ID)
providerID := appContainerProviderID(resource)
if strings.EqualFold(reference, canonicalID) || strings.EqualFold(reference, providerID) {
return true
}
if resource.Docker == nil || providerID == "" {
return false
}
for _, host := range []string{resource.Docker.AgentID, resource.Docker.HostSourceID, resource.Docker.Hostname} {
host = strings.TrimSpace(host)
if host != "" && strings.EqualFold(reference, "docker:"+host+":"+providerID) {
return true
}
}
return false
}
func stringArg(args map[string]interface{}, key string) string {
value, _ := args[key].(string)
return value