diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 180b6c32b..4bde1444b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/internal/ai/chat/compaction_test.go b/internal/ai/chat/compaction_test.go index 0a8c5c2ce..13f13baa8 100644 --- a/internal/ai/chat/compaction_test.go +++ b/internal/ai/chat/compaction_test.go @@ -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 diff --git a/internal/ai/chat/knowledge_extractor.go b/internal/ai/chat/knowledge_extractor.go index 090408c89..644fb3428 100644 --- a/internal/ai/chat/knowledge_extractor.go +++ b/internal/ai/chat/knowledge_extractor.go @@ -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 { diff --git a/internal/ai/chat/knowledge_extractor_test.go b/internal/ai/chat/knowledge_extractor_test.go index fd68cf4e8..fe4c91eab 100644 --- a/internal/ai/chat/knowledge_extractor_test.go +++ b/internal/ai/chat/knowledge_extractor_test.go @@ -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 diff --git a/internal/ai/tools/proposal_capture_test.go b/internal/ai/tools/proposal_capture_test.go index 337a6fb7b..43f2dc191 100644 --- a/internal/ai/tools/proposal_capture_test.go +++ b/internal/ai/tools/proposal_capture_test.go @@ -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{ diff --git a/internal/ai/tools/tools_propose.go b/internal/ai/tools/tools_propose.go index 00309fb47..a00c941e9 100644 --- a/internal/ai/tools/tools_propose.go +++ b/internal/ai/tools/tools_propose.go @@ -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