mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
Preserve Patrol investigation evidence continuity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user