mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-16 12:33:29 +00:00
Harden Patrol qualification lifecycle
This commit is contained in:
parent
88c380b514
commit
effcf2d50c
13 changed files with 192 additions and 13 deletions
|
|
@ -7514,3 +7514,26 @@ large probe context and then silently truncate live infrastructure context or
|
|||
governed tool schemas at Ollama's smaller server default. Output-token and
|
||||
reasoning allowances remain separately bounded and do not expand mutation
|
||||
authority.
|
||||
|
||||
### Watch lifecycle closure and subscription receipts fail closed
|
||||
|
||||
Watch exposes one existing-finding lifecycle contract to the selected model:
|
||||
`patrol_assess_finding` with an explicit `present`, `resolved`, or `uncertain`
|
||||
verdict. The legacy `patrol_resolve_finding` handler remains registered only as
|
||||
a compatibility boundary for older internal callers; the Patrol detection
|
||||
profile neither projects nor executes it. Scoped Watch manifests derive from
|
||||
the same shared detection-tool vocabulary, so resource-type minimization cannot
|
||||
reintroduce that compatibility handler after profile projection. A resolved assessment still crosses
|
||||
the same server-owned verification boundary. In particular, a health finding
|
||||
for an app container cannot close while the exact current Patrol snapshot
|
||||
continues to report a non-healthy provider health value, even if model prose or
|
||||
the selected tool claims otherwise.
|
||||
|
||||
Local subscription transports retain provider-owned usage receipts without
|
||||
making token fields part of the model-authored structured turn. Claude usage
|
||||
comes from its terminal result envelope. Codex usage comes from the terminal
|
||||
`turn.completed` event emitted by `codex exec --json` and is joined to the
|
||||
strict last-message payload only after both have passed transport validation.
|
||||
Qualification may therefore require positive input/output usage for a
|
||||
tool-free real-model all-clear without falsely rejecting Codex subscription
|
||||
runs whose analysis and healthy outcome were durably persisted.
|
||||
|
|
|
|||
|
|
@ -9515,8 +9515,11 @@ Exact scoped Watch runs may now populate that internal allowlist from the
|
|||
core-resolved canonical resource type set. The shared
|
||||
`agentcapabilities.PatrolDetectionToolNamesForResourceTypes` mapping retains
|
||||
canonical query evidence and only the relevant subsystem and detection
|
||||
lifecycle schemas; optional agent-routed deep reads are deferred to the
|
||||
investigation boundary. Investigations carry their finding `resource_type` over
|
||||
lifecycle schemas. That mapping exposes `patrol_assess_finding` as the only
|
||||
existing-finding verdict path and omits the legacy direct
|
||||
`patrol_resolve_finding` compatibility handler, matching the detection
|
||||
execution profile's fail-closed projection. Optional agent-routed deep reads
|
||||
are deferred to the investigation boundary. Investigations carry their finding `resource_type` over
|
||||
the public Pulse/Enterprise orchestrator dependency contract and use the companion
|
||||
investigation projection. That projection intersects optional evidence tools
|
||||
with the runtime-available governed profile, while requiring at least one
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ func PatrolDetectionToolNamesForResourceTypes(resourceTypes []string) ([]string,
|
|||
PatrolGetFindingsToolName,
|
||||
PatrolReportFindingToolName,
|
||||
PatrolAssessFindingToolName,
|
||||
PatrolResolveFindingToolName,
|
||||
), true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,14 @@ func TestPatrolScopedToolProjectionComposesProfileOwnedTools(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatal("network-endpoint scope was not recognized")
|
||||
}
|
||||
for _, required := range []string{PulseQueryToolName, PulseDiscoveryToolName, PatrolGetFindingsToolName, PatrolReportFindingToolName, PatrolAssessFindingToolName, PatrolResolveFindingToolName} {
|
||||
for _, required := range []string{PulseQueryToolName, PulseDiscoveryToolName, PatrolGetFindingsToolName, PatrolReportFindingToolName, PatrolAssessFindingToolName} {
|
||||
if !slices.Contains(detection, required) {
|
||||
t.Fatalf("detection surface missing %q: %v", required, detection)
|
||||
}
|
||||
}
|
||||
if slices.Contains(detection, PatrolResolveFindingToolName) {
|
||||
t.Fatalf("detection surface exposed the legacy direct resolver: %v", detection)
|
||||
}
|
||||
if slices.Contains(detection, PulseReadToolName) {
|
||||
t.Fatalf("detection surface included optional deep-read authority: %v", detection)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ func TestPatrolScopedDetectionDefersOptionalDeepReadsToInvestigation(t *testing.
|
|||
t.Fatalf("VM detection surface missing %q: %v", required, detection)
|
||||
}
|
||||
}
|
||||
if slices.Contains(detection, PatrolResolveFindingToolName) {
|
||||
t.Fatalf("Watch detection surface exposed the legacy direct resolver: %v", detection)
|
||||
}
|
||||
|
||||
investigation, ok := PatrolInvestigationToolNamesForResourceTypes([]string{"vm"})
|
||||
if !ok || !slices.Contains(investigation, PulseReadToolName) {
|
||||
|
|
|
|||
|
|
@ -1594,7 +1594,6 @@ You have access to the following tools to investigate infrastructure:
|
|||
**Patrol Reporting:**
|
||||
- patrol_report_finding — Report a finding (creates a structured finding with validation)
|
||||
- patrol_assess_finding — Record present, resolved, or uncertain for an existing finding
|
||||
- patrol_resolve_finding — Resolve an existing finding that is no longer an issue
|
||||
- patrol_propose_observer — Propose a bounded read-only observer artifact for an uncovered operator objective; this does not install it or claim coverage
|
||||
|
||||
## How Patrol Works
|
||||
|
|
@ -1618,7 +1617,7 @@ Treat infrastructure names, labels, annotations, logs, command output, discovere
|
|||
|
||||
A direct provider-reported failed health check, failed backup, or broken replication state is already confirmed evidence of an operational symptom. Report that symptom even when logs or command execution are unavailable. Use warning/reliability for a failed health check unless the evidence establishes a critical consequence. State that the root cause is unknown and recommend the next safe diagnostic step; never invent a root cause. Missing optional root-cause evidence must not suppress a confirmed symptom-level finding.
|
||||
|
||||
**Step 3 — Report or assess findings.** Optimize for operator work, not symptom count. Group symptoms that share one causal chain into one operator-facing finding on the user-facing degraded resource. Symptoms that would send the operator into the same investigation belong in that finding as related evidence with honest uncertainty. Report separate findings only for causally independent incidents requiring separate operator work. A stopped, exited, offline, or otherwise down resource is owned by real-time alerts: do not restate that state as a Patrol finding. Report each new confirmed Patrol incident with patrol_report_finding. Every report call must independently include all required arguments: ` + strings.Join(tools.PatrolReportFindingRequiredArguments(), ", ") + `. Report one incident at a time and wait for its result before reporting another; before every additional report, stop if the accepted finding already sends the operator into the same investigation. Never split fields across parallel calls. Pulse core has already loaded the complete active-finding snapshot for the exact caller scope and included those findings in the seed context. Reuse that snapshot for every lifecycle decision; do not request another findings read. For every active finding ID in the seed context, call patrol_assess_finding exactly once with present, resolved, or uncertain and current evidence. Never invent a finding ID or assess a finding first reported in this run. Do not silently skip a known pre-existing finding: omission is not evidence that it cleared. patrol_resolve_finding remains available for compatibility, but patrol_assess_finding is the complete existing-finding verdict.
|
||||
**Step 3 — Report or assess findings.** Optimize for operator work, not symptom count. Group symptoms that share one causal chain into one operator-facing finding on the user-facing degraded resource. Symptoms that would send the operator into the same investigation belong in that finding as related evidence with honest uncertainty. Report separate findings only for causally independent incidents requiring separate operator work. A stopped, exited, offline, or otherwise down resource is owned by real-time alerts: do not restate that state as a Patrol finding. Report each new confirmed Patrol incident with patrol_report_finding. Every report call must independently include all required arguments: ` + strings.Join(tools.PatrolReportFindingRequiredArguments(), ", ") + `. Report one incident at a time and wait for its result before reporting another; before every additional report, stop if the accepted finding already sends the operator into the same investigation. Never split fields across parallel calls. Pulse core has already loaded the complete active-finding snapshot for the exact caller scope and included those findings in the seed context. Reuse that snapshot for every lifecycle decision; do not request another findings read. For every active finding ID in the seed context, call patrol_assess_finding exactly once with present, resolved, or uncertain and current evidence. Never invent a finding ID or assess a finding first reported in this run. Do not silently skip a known pre-existing finding: omission is not evidence that it cleared. patrol_assess_finding is the only existing-finding lifecycle tool exposed to Watch; its resolved verdict retains the deterministic fail-closed verification boundary.
|
||||
|
||||
**Operator objectives.** Objectives are retained outcomes, not scripts. When an active objective is explicitly marked observer_missing, use current estate context to call patrol_propose_observer once with the smallest useful read-only local observer design. Use the generic resource-state, resource-metric, or existing-availability-target interval ABI when canonical estate evidence measures the outcome directly; those observers run locally and never poll the model. A correlated signal that only indicates the outcome may be impaired is a proxy, not direct coverage: label it evidence_fit proxy so Pulse can use the cheap wake signal without claiming the full objective is covered. Prefer event-driven evidence for richer designs. Do not re-propose an observer already marked proposed, validated, installed, or degraded unless the current evidence explicitly requires a new design. A successful proposal remains uncovered until core validates, installs, evaluates, and leases it; a healthy proxy remains uncovered until direct evidence exists. Never describe proposal creation or proxy installation as full monitoring coverage.
|
||||
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,28 @@ func (a *patrolFindingCreatorAdapter) ResolveFinding(findingID, reason string) e
|
|||
return fmt.Errorf("finding %s is outside the current patrol scope", findingID)
|
||||
}
|
||||
|
||||
// A model-owned lifecycle verdict must not override contradictory current
|
||||
// provider state. Container health is already present in the exact Patrol
|
||||
// snapshot and needs no heuristic verifier or model interpretation: when a
|
||||
// health finding's resource still reports a non-healthy value, closure is
|
||||
// false and must fail before any durable mutation. This also protects the
|
||||
// legacy direct-resolve compatibility handler if an older caller reaches it.
|
||||
if findingDescribesAppContainerHealth(finding) {
|
||||
for _, row := range patrolAppContainerRows(a.snap, nil) {
|
||||
if row.id != finding.ResourceID && row.name != finding.ResourceID && row.name != finding.ResourceName {
|
||||
continue
|
||||
}
|
||||
health := strings.ToLower(strings.TrimSpace(row.health))
|
||||
if health != "" && health != "healthy" {
|
||||
return fmt.Errorf(
|
||||
"cannot resolve %s: current provider state still reports container health %s",
|
||||
findingID, health,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Event/persistent categories (backup, reliability, security, general)
|
||||
// must not be auto-resolved on absence — see the contract at
|
||||
// findings.go:CategorySupportsStaleAutoResolve. Before today's gate,
|
||||
|
|
@ -1113,6 +1135,18 @@ func (a *patrolFindingCreatorAdapter) ResolveFinding(findingID, reason string) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func findingDescribesAppContainerHealth(finding *Finding) bool {
|
||||
if finding == nil || finding.ResourceType != "app-container" {
|
||||
return false
|
||||
}
|
||||
classification := strings.ToLower(strings.Join([]string{
|
||||
finding.Key,
|
||||
finding.Title,
|
||||
finding.Description,
|
||||
}, " "))
|
||||
return strings.Contains(classification, "health")
|
||||
}
|
||||
|
||||
func (a *patrolFindingCreatorAdapter) GetActiveFindings(resourceID, minSeverity string) []tools.PatrolFindingInfo {
|
||||
a.findingsMu.Lock()
|
||||
a.checkedFindings = true
|
||||
|
|
|
|||
|
|
@ -1138,6 +1138,57 @@ func TestPatrolFindingCreatorAdapter_ResolveFinding_RejectsOutOfScopeFinding(t *
|
|||
}
|
||||
}
|
||||
|
||||
func TestPatrolFindingCreatorAdapter_ResolveFinding_RejectsContradictoryContainerHealth(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
finding := &Finding{
|
||||
ID: "container-health-active",
|
||||
Key: "health-check-failed",
|
||||
Severity: FindingSeverityWarning,
|
||||
Category: FindingCategoryReliability,
|
||||
ResourceID: "app-1",
|
||||
ResourceName: "web",
|
||||
ResourceType: "app-container",
|
||||
Title: "Container health check is failing",
|
||||
Description: "The provider health check is unhealthy",
|
||||
DetectedAt: time.Now().Add(-time.Hour),
|
||||
LastSeenAt: time.Now().Add(-time.Hour),
|
||||
}
|
||||
ps.findings.Add(finding)
|
||||
|
||||
unhealthy := newPatrolRuntimeState(models.StateSnapshot{
|
||||
DockerHosts: []models.DockerHost{{
|
||||
ID: "docker-host-1",
|
||||
Containers: []models.DockerContainer{{
|
||||
ID: "app-1", Name: "web", State: "running", Health: "unhealthy",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
adapter := newPatrolFindingCreatorAdapterState(ps, unhealthy)
|
||||
err := adapter.ResolveFinding(finding.ID, "model claimed the issue cleared")
|
||||
if err == nil || !strings.Contains(err.Error(), "still reports container health unhealthy") {
|
||||
t.Fatalf("contradictory resolution error = %v", err)
|
||||
}
|
||||
if stored := ps.findings.Get(finding.ID); stored == nil || stored.IsResolved() {
|
||||
t.Fatalf("contradictory provider state must keep finding active: %+v", stored)
|
||||
}
|
||||
|
||||
healthy := newPatrolRuntimeState(models.StateSnapshot{
|
||||
DockerHosts: []models.DockerHost{{
|
||||
ID: "docker-host-1",
|
||||
Containers: []models.DockerContainer{{
|
||||
ID: "app-1", Name: "web", State: "running", Health: "healthy",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
adapter = newPatrolFindingCreatorAdapterState(ps, healthy)
|
||||
if err := adapter.ResolveFinding(finding.ID, "provider health is now healthy"); err != nil {
|
||||
t.Fatalf("healthy provider state should permit lifecycle resolution: %v", err)
|
||||
}
|
||||
if stored := ps.findings.Get(finding.ID); stored == nil || !stored.IsResolved() {
|
||||
t.Fatalf("healthy provider state did not resolve finding: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolService_GetAllFindingsIncludingResolved_IncludesResolvedAndDismissedSortsActiveFirst(t *testing.T) {
|
||||
// GetAllFindingsIncludingResolved is the audit-trail accessor used by the
|
||||
// Patrol UI's Resolved tab. Until it landed, the trust strip credited
|
||||
|
|
|
|||
|
|
@ -100,6 +100,14 @@ type claudeStreamEvent struct {
|
|||
} `json:"message"`
|
||||
}
|
||||
|
||||
type codexStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type subscriptionAgentCommandError struct {
|
||||
command string
|
||||
cause error
|
||||
|
|
@ -239,6 +247,7 @@ func (c *SubscriptionAgentClient) Chat(ctx context.Context, req ChatRequest) (*C
|
|||
defer os.RemoveAll(workdir)
|
||||
|
||||
var raw []byte
|
||||
var codexEvents []byte
|
||||
switch c.agent {
|
||||
case SubscriptionAgentCodex:
|
||||
prompt := append([]byte(subscriptionAgentControlPrompt+"\n\n"), requestPrompt...)
|
||||
|
|
@ -250,10 +259,9 @@ func (c *SubscriptionAgentClient) Chat(ctx context.Context, req ChatRequest) (*C
|
|||
args := []string{"exec", "--json", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--skip-git-repo-check", "--sandbox", "read-only", "--config", `web_search="disabled"`, "--config", `shell_environment_policy.inherit="none"`, "--config", `shell_environment_policy.set.PATH="/usr/bin:/bin"`}
|
||||
args = append(args, effortArgs...)
|
||||
args = append(args, "--model", c.model, "--output-schema", schemaPath, "--output-last-message", outputPath, "-")
|
||||
var events []byte
|
||||
events, err = c.run(ctx, "codex", args, prompt, workdir)
|
||||
codexEvents, err = c.run(ctx, "codex", args, prompt, workdir)
|
||||
if err == nil {
|
||||
err = rejectCodexAgentToolActivity(events)
|
||||
err = rejectCodexAgentToolActivity(codexEvents)
|
||||
}
|
||||
if err == nil {
|
||||
raw, err = os.ReadFile(outputPath)
|
||||
|
|
@ -287,10 +295,33 @@ func (c *SubscriptionAgentClient) Chat(ctx context.Context, req ChatRequest) (*C
|
|||
if err := validateSubscriptionAgentTurn(req, &turn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.agent == SubscriptionAgentCodex {
|
||||
turn.InputTokens, turn.OutputTokens = decodeCodexSubscriptionAgentUsage(codexEvents)
|
||||
}
|
||||
response := ChatResponse{Content: turn.Content, Model: c.model, StopReason: turn.StopReason, ToolCalls: turn.ProviderToolCalls, InputTokens: turn.InputTokens, OutputTokens: turn.OutputTokens}
|
||||
return response.NormalizeCollectionsPtr(), nil
|
||||
}
|
||||
|
||||
// decodeCodexSubscriptionAgentUsage retains the usage receipt emitted by
|
||||
// `codex exec --json`. The structured last-message file intentionally contains
|
||||
// only Pulse's strict assistant-turn schema, so usage must be joined from the
|
||||
// terminal turn.completed event instead of being model-authored fields.
|
||||
func decodeCodexSubscriptionAgentUsage(raw []byte) (inputTokens, outputTokens int) {
|
||||
for _, line := range bytes.Split(raw, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var event codexStreamEvent
|
||||
if err := json.Unmarshal(line, &event); err != nil || event.Type != "turn.completed" {
|
||||
continue
|
||||
}
|
||||
inputTokens = event.Usage.InputTokens
|
||||
outputTokens = event.Usage.OutputTokens
|
||||
}
|
||||
return inputTokens, outputTokens
|
||||
}
|
||||
|
||||
func subscriptionAgentReasoningEffortArgs(agent SubscriptionAgent, effort ReasoningEffort) ([]string, error) {
|
||||
if !effort.Valid() {
|
||||
return nil, fmt.Errorf("unsupported subscription-agent reasoning effort %q", effort)
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ while [ "$#" -gt 0 ]; do
|
|||
if [ "$1" = "--output-last-message" ]; then
|
||||
shift
|
||||
printf '%s' '{"content":"","stop_reason":"tool_use","tool_calls":[{"id":"c1","name":"get_node_status","input":{"node":"tower"}}]}' > "$1"
|
||||
printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":21,"cached_input_tokens":8,"output_tokens":5}}'
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
|
|
@ -411,6 +412,9 @@ printf '%s' '{"structured_output":{"content":"healthy","stop_reason":"end_turn",
|
|||
if response.Model != "gpt-5.6-luna" {
|
||||
t.Fatalf("Codex response model = %q, want bare CLI model", response.Model)
|
||||
}
|
||||
if response.InputTokens != 21 || response.OutputTokens != 5 {
|
||||
t.Fatalf("Codex response usage = %d/%d, want 21/5", response.InputTokens, response.OutputTokens)
|
||||
}
|
||||
var streamEvents []StreamEvent
|
||||
if err := codex.ChatStream(ctx, ChatRequest{ReasoningEffort: ReasoningEffortLow, Tools: []Tool{{Name: "get_node_status"}}, ToolChoice: &ToolChoice{Type: ToolChoiceRequired}}, func(event StreamEvent) {
|
||||
streamEvents = append(streamEvents, event)
|
||||
|
|
@ -425,7 +429,7 @@ printf '%s' '{"structured_output":{"content":"healthy","stop_reason":"end_turn",
|
|||
t.Fatalf("Codex tool_start = %#v", streamEvents[0].Data)
|
||||
}
|
||||
done, ok := streamEvents[1].Data.(DoneEvent)
|
||||
if !ok || done.StopReason != "tool_use" || len(done.ToolCalls) != 1 {
|
||||
if !ok || done.StopReason != "tool_use" || len(done.ToolCalls) != 1 || done.InputTokens != 21 || done.OutputTokens != 5 {
|
||||
t.Fatalf("Codex done = %#v", streamEvents[1].Data)
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +446,19 @@ printf '%s' '{"structured_output":{"content":"healthy","stop_reason":"end_turn",
|
|||
}
|
||||
}
|
||||
|
||||
func TestDecodeCodexSubscriptionAgentUsageUsesTerminalCompletedTurn(t *testing.T) {
|
||||
raw := []byte(strings.Join([]string{
|
||||
`{"type":"thread.started","thread_id":"thread-1"}`,
|
||||
`{"type":"turn.completed","usage":{"input_tokens":11,"cached_input_tokens":3,"output_tokens":4}}`,
|
||||
`not-json`,
|
||||
`{"type":"turn.completed","usage":{"input_tokens":19,"cached_input_tokens":7,"output_tokens":6}}`,
|
||||
}, "\n"))
|
||||
inputTokens, outputTokens := decodeCodexSubscriptionAgentUsage(raw)
|
||||
if inputTokens != 19 || outputTokens != 6 {
|
||||
t.Fatalf("Codex usage = %d/%d, want terminal 19/6", inputTokens, outputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionAgentReasoningEffortArguments(t *testing.T) {
|
||||
claude, err := subscriptionAgentReasoningEffortArgs(SubscriptionAgentClaude, ReasoningEffortLow)
|
||||
if err != nil || strings.Join(claude, " ") != "--effort low" {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ const (
|
|||
// non-interactive, no infrastructure mutations, and Pulse-state
|
||||
// mutations restricted to the finding lifecycle and observer-proposal tools
|
||||
// (patrol_report_finding / patrol_assess_finding /
|
||||
// patrol_resolve_finding / patrol_propose_observer).
|
||||
// patrol_propose_observer). The legacy direct-resolve alias is deliberately
|
||||
// absent: all existing-finding closure crosses the assessed lifecycle.
|
||||
ProfilePatrolDetection
|
||||
// ProfilePatrolInvestigation is the Patrol investigation posture:
|
||||
// non-interactive and structurally read-only - no infrastructure and
|
||||
|
|
@ -65,7 +66,6 @@ func patrolDetectionPulseStateAllowlist() map[string]bool {
|
|||
agentcapabilities.PatrolAssessFindingToolName: true,
|
||||
agentcapabilities.PatrolProposeObserverToolName: true,
|
||||
agentcapabilities.PatrolReportFindingToolName: true,
|
||||
agentcapabilities.PatrolResolveFindingToolName: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -454,6 +454,11 @@ func TestPatrolDetectionProfileEnforcesAllowlistedPulseState(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Content)
|
||||
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
||||
text = executeBlockedText(t, exec, agentcapabilities.PatrolResolveFindingToolName, map[string]interface{}{
|
||||
agentcapabilities.FindingIDArgumentName: "finding-1",
|
||||
agentcapabilities.ReasonArgumentName: "claimed resolved",
|
||||
})
|
||||
assert.Contains(t, text, "Invocation blocked")
|
||||
|
||||
// Projection agrees: alerts offers only its read subactions.
|
||||
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
||||
|
|
|
|||
|
|
@ -885,9 +885,20 @@ func TestPatrolToolsRegistered(t *testing.T) {
|
|||
|
||||
assert.True(t, found["patrol_report_finding"], "patrol_report_finding should be registered")
|
||||
assert.True(t, found["patrol_assess_finding"], "patrol_assess_finding should be registered")
|
||||
assert.True(t, found["patrol_resolve_finding"], "patrol_resolve_finding should be registered")
|
||||
assert.False(t, found["patrol_resolve_finding"], "legacy direct resolve must not be projected into Patrol detection")
|
||||
assert.True(t, found["patrol_get_findings"], "patrol_get_findings should be registered")
|
||||
assert.True(t, found["patrol_propose_observer"], "patrol_propose_observer should be registered")
|
||||
for _, tool := range exec.registry.ListTools(InvocationPolicy{
|
||||
ControlLevel: ControlLevelControlled,
|
||||
PulseStateAllowlist: map[string]bool{
|
||||
agentcapabilities.PatrolResolveFindingToolName: true,
|
||||
},
|
||||
Profile: ProfileInteractiveAssistant,
|
||||
}) {
|
||||
if tool.Name == agentcapabilities.PatrolResolveFindingToolName {
|
||||
resolveTool = tool
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, resolveTool.Name)
|
||||
assert.Contains(t, resolveTool.InputSchema.Required, agentcapabilities.FindingIDArgumentName)
|
||||
assert.Contains(t, resolveTool.InputSchema.Required, agentcapabilities.ReasonArgumentName)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue