From 20c2f270576b21a5af84ded336bdacb97c79a4b3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 3 Jul 2026 17:07:41 +0100 Subject: [PATCH] feat(mcp): add list_resource_capabilities tool Agents had no way to discover which governed capabilities a resource advertises before calling plan_action. The data existed (Resource.Capabilities with full param schemas) but was only surfaced as count-limited prose facts inside get_resource_context, which deliberately omits parameter schemas. This forced agents to guess capabilityName and params. Add a dedicated structured surface: GET /api/agent/resource-capabilities/{id} returns the canonical ResourceCapability list (name, type, approval level, platform, full parameter schemas) for a single resource. A resource with no advertised capabilities returns 200 with an empty array, the signal to skip plan_action. Internal plumbing already existed (registry.Get + Resource.Capabilities, the same path plan_action validates against); this is the wiring work at the canonical agent-surface layer. The tools/call dispatch is fully generic so no mcp.go changes were needed. Companion to get_resource_context: that tool remains the human-readable prose summary; this is the structured schema surface for action planning. - Manifest capability, name/path constants, output schema helper - Handler mirroring HandleResourceContext error/scope patterns - Route registration behind monitoring:read - Activity telemetry mapping to resource_context class - Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs - Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery - Tests: manifest ownership/addressing/table-test, handler happy/empty/404/405, contract pinning capability presence/scope/wire-shape --- cmd/pulse-mcp/README.md | 2 + .../v6/internal/subsystems/agent-lifecycle.md | 8 +- .../v6/internal/subsystems/ai-runtime.md | 7 +- .../v6/internal/subsystems/api-contracts.md | 30 +++- .../internal/subsystems/storage-recovery.md | 6 +- internal/agentcapabilities/manifest.go | 23 +++ internal/agentcapabilities/manifest_test.go | 24 ++- internal/agentcapabilities/projection.go | 9 + internal/api/agent_activity_telemetry.go | 3 +- internal/api/agent_capabilities_test.go | 7 +- internal/api/agent_resource_context.go | 79 +++++++++ internal/api/agent_resource_context_test.go | 167 ++++++++++++++++++ internal/api/contract_test.go | 48 +++++ internal/api/route_inventory_test.go | 1 + internal/api/router_routes_monitoring.go | 9 + 15 files changed, 404 insertions(+), 19 deletions(-) diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index d64b308ec..9ac0381f7 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -228,6 +228,7 @@ the live set. **Context (read-only):** - `get_resource_context` (Get resource context, `GET /api/agent/resource-context/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute. +- `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array. - `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. - `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output. @@ -311,6 +312,7 @@ Capability-specific stable codes are advertised by the manifest: - `get_resource_context`: `resource_not_found` +- `list_resource_capabilities`: `resource_not_found` - `get_operator_state`: `operator_state_not_set` - `set_operator_state`: `operator_state_invalid` - `acknowledge_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b943f49b5..173c1f554 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1651,7 +1651,13 @@ import; the parallel `AgentApprovalsProvider` adapter resolves the canonical approval store at request time, filters full per-resource summaries by canonical resource id and org, and exposes a separate resource-keyed count projection for fleet reads. The agent runtime -keeps both providers wired across restarts. +keeps both providers wired across restarts. The companion +`/api/agent/resource-capabilities/{id}` endpoint (same +`monitoring:read` scope) returns the structured governed +capability list and parameter schemas for the same resource, so an +agent can populate `plan_action` inputs from the canonical +`Resource.Capabilities` source rather than the prose summary in +the context bundle. `/api/agent/fleet-context` is the companion triage view: one read returns a thin per-resource rollup across every resource visible diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c5147b1aa..1624edcad 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -361,7 +361,12 @@ older clients. That shared Patrol work tool set starts with `get_patrol_control_status`, the count-only `GET /api/agent/patrol-control/status` orientation read, then uses the same fleet-context, resource-context, finding, governed action, and finding -resolution tools as the rest of the governed issue-handling flow. +resolution tools as the rest of the governed issue-handling flow. The +structured `list_resource_capabilities` read is the companion to +resource-context for action planning: it returns the per-resource +governed capability names and parameter schemas an agent needs to +populate `plan_action` inputs without guessing, where resource-context +renders capabilities only as count-limited prose facts. `get_operations_loop_status` and `/api/agent/operations-loop/status` are compatibility aliases, not primary capability names. AI runtime prompts, MCP readiness helpers, and frontend presentation helpers must not carry separate diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9a8ee9557..af6cd99e8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1878,11 +1878,11 @@ a new API state machine, queue contract, or verification-accounting field. overdue scheduled protection only from API-owned Patrol status, run history, approval state, and finding-composition facts rather than page-local inference - and the calm-day Patrol protection-posture inputs, where the browser may - summarize protection-current, coverage, schedule freshness, drift history, - and verification-waiting state only from API-owned Patrol status, run - history, approval state, and finding-composition facts after current work has - been ruled out + and the calm-day Patrol empty queue boundary, where the browser must not + turn API-owned Patrol status, run history, approval state, or + finding-composition facts into a protection-current, coverage, + schedule-freshness, drift-history, verification-waiting, trust, or proof + strip after current work has been ruled out while keeping the learning counters backend-only coverage, so Patrol keeps health and findings primary and renders timeline, correlation, and policy-posture data as selected-item investigation context rather than as a separate headline product metric and that secondary investigation context remains explanatory API evidence, not a default workspace mode: the Patrol page may expand recent-change, @@ -3445,7 +3445,7 @@ There are two layers of stable codes. First, **capability-specific codes** are declared per capability in the manifest's `errorCodes` list and represent the closed set of failure modes that capability's handler emits. Today these are: -`resource_not_found` (`get_resource_context`), +`resource_not_found` (`get_resource_context`, `list_resource_capabilities`), `operator_state_not_set` (`get_operator_state`), `operator_state_invalid` (`set_operator_state`); `invalid_finding_request`, `finding_not_found`, @@ -3694,6 +3694,24 @@ of the bounded pending-approval list, not N calls to the per-resource approval summary helper. Agents pick a focus from the fleet view, then drill into the per-resource bundle for depth. +`/api/agent/resource-capabilities/{id}` is the agent-consumable +structured capability surface for a single resource: the governed +capabilities it advertises (name, type, approval level, platform, and +full parameter schemas). It is the companion to the resource-context +bundle: the bundle renders capabilities as count-limited prose facts +(`formatCapabilityFact` omits parameter schemas and caps at five +entries), while this endpoint returns the structured list an agent +needs to populate `plan_action.capabilityName` and `params` without +guessing. The capability list is the in-memory +`Resource.Capabilities` slice assembled by the registry — the same +canonical source `plan_action` validates against — so the two +endpoints cannot drift on what a resource can do. A resource +advertising nothing returns 200 with `capabilities: []`, the signal +an agent should not attempt `plan_action` against it; the array is +always non-nil on the wire. The internal `InternalHandler` field is +excluded from JSON via its `json:"-"` tag so execution plumbing does +not leak to the public surface. + The TS client `frontend-modern/src/api/resourceOperatorState.ts` mirrors the canonical Go shape from `internal/unifiedresources/resource_operator_state.go` and exposes diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c856fbfb8..c27e8ecc0 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1726,7 +1726,11 @@ in-memory findings store, and the same in-memory approval store. Fleet pending-approval counts may be grouped by canonical resource id from one bounded approval-store scan, but that is still a read-only projection over `approvals.json`, not a new recovery -artifact or storage/recovery freshness signal. The recovery posture +artifact or storage/recovery freshness signal. The companion +`/api/agent/resource-capabilities/{id}` endpoint is the same kind +of read-only projection: it returns the in-memory +`Resource.Capabilities` slice the registry already assembles, so +no new persistence or recovery artifact is introduced. The recovery posture is identical to the per-resource bundle: when the unified-resources store and the approval store rehydrate on startup, the fleet view recovers the same situated picture without any fleet-specific diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 62155f031..23a159ae3 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -80,6 +80,14 @@ func resourceContextOutputSchema() json.RawMessage { }) } +func resourceCapabilitiesOutputSchema() json.RawMessage { + return agentObjectOutputSchema([]string{"resourceId", "capabilities", "generatedAt"}, map[string]any{ + "resourceId": stringOption("Canonical Pulse resource id the capabilities belong to."), + "capabilities": objectArrayOption("Advertised governed capabilities with full parameter schemas; empty array when the resource advertises none."), + "generatedAt": dateTimeOption("Server timestamp for this capability snapshot."), + }) +} + func operatorStateOutputSchema() json.RawMessage { return agentObjectOutputSchema([]string{"canonicalId", "intentionallyOffline", "neverAutoRemediate", "setAt"}, map[string]any{ "canonicalId": stringOption("Canonical Pulse resource id."), @@ -696,6 +704,20 @@ var canonicalManifest = Manifest{ OutputSchema: resourceContextOutputSchema(), ErrorCodes: []string{AgentErrCodeResourceNotFound}, }, + { + Name: ListResourceCapabilitiesCapabilityName, + Title: "List resource capabilities", + Description: "Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array.", + Category: "context", + Method: http.MethodGet, + Path: ListResourceCapabilitiesCapabilityPath, + Scope: agentCapabilityScopeMonitoringRead, + ActionMode: agentCapabilityActionModeRead, + ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly, + ResponseShape: "AgentResourceCapabilities", + OutputSchema: resourceCapabilitiesOutputSchema(), + ErrorCodes: []string{AgentErrCodeResourceNotFound}, + }, { Name: "get_fleet_context", Title: "Get fleet context", @@ -1045,6 +1067,7 @@ func CanonicalManifest() Manifest { func canonicalPulseMCPSurfaceToolNames() []string { return []string{ ResourceContextCapabilityName, + ListResourceCapabilitiesCapabilityName, FleetContextCapabilityName, OperationsLoopStatusCapabilityName, ListNodesCapabilityName, diff --git a/internal/agentcapabilities/manifest_test.go b/internal/agentcapabilities/manifest_test.go index 47087a003..fd1211bd3 100644 --- a/internal/agentcapabilities/manifest_test.go +++ b/internal/agentcapabilities/manifest_test.go @@ -23,6 +23,7 @@ func TestCanonicalManifestOwnsAgentSurface(t *testing.T) { for _, name := range []string{ FleetContextCapabilityName, ResourceContextCapabilityName, + ListResourceCapabilitiesCapabilityName, OperationsLoopStatusCapabilityName, SetOperatorStateCapabilityName, ListFindingsCapabilityName, @@ -72,12 +73,13 @@ func TestCanonicalManifestUsesSharedResourceContextAddressing(t *testing.T) { manifest := CanonicalManifest() for name, path := range map[string]string{ - FleetContextCapabilityName: FleetContextCapabilityPath, - ResourceContextCapabilityName: ResourceContextCapabilityPath, - OperationsLoopStatusCapabilityName: OperationsLoopStatusCapabilityPath, - GetOperatorStateCapabilityName: OperatorStateCapabilityPath, - SetOperatorStateCapabilityName: OperatorStateCapabilityPath, - ClearOperatorStateCapabilityName: OperatorStateCapabilityPath, + FleetContextCapabilityName: FleetContextCapabilityPath, + ResourceContextCapabilityName: ResourceContextCapabilityPath, + ListResourceCapabilitiesCapabilityName: ListResourceCapabilitiesCapabilityPath, + OperationsLoopStatusCapabilityName: OperationsLoopStatusCapabilityPath, + GetOperatorStateCapabilityName: OperatorStateCapabilityPath, + SetOperatorStateCapabilityName: OperatorStateCapabilityPath, + ClearOperatorStateCapabilityName: OperatorStateCapabilityPath, } { cap, ok := FindCapability(manifest.Capabilities, name) if !ok { @@ -415,6 +417,16 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test approval: ApprovalPolicyScopeOnly, errorCodes: []string{AgentErrCodeResourceNotFound}, }, + { + name: ListResourceCapabilitiesCapabilityName, + category: "context", + method: http.MethodGet, + path: ListResourceCapabilitiesCapabilityPath, + scope: auth.ScopeMonitoringRead, + mode: ActionModeRead, + approval: ApprovalPolicyScopeOnly, + errorCodes: []string{AgentErrCodeResourceNotFound}, + }, { name: ListFindingsCapabilityName, category: "finding", diff --git a/internal/agentcapabilities/projection.go b/internal/agentcapabilities/projection.go index d3c7b78d2..695ebdf49 100644 --- a/internal/agentcapabilities/projection.go +++ b/internal/agentcapabilities/projection.go @@ -13,6 +13,12 @@ const ( FleetContextCapabilityName = "get_fleet_context" // ResourceContextCapabilityName is the manifest-owned per-resource depth read. ResourceContextCapabilityName = "get_resource_context" + // ListResourceCapabilitiesCapabilityName is the manifest-owned per-resource + // structured capability surface (names + parameter schemas). Companion to + // get_resource_context: that tool renders capabilities as count-limited prose + // facts (omitting parameter schemas); this tool returns the structured list + // an agent needs to populate plan_action.capabilityName and params. + ListResourceCapabilitiesCapabilityName = "list_resource_capabilities" // PatrolControlStatusCapabilityName is the manifest-owned Patrol control // status read. It is count-only and content-safe so external agents can // orient on the same Patrol -> Assistant -> governed action -> @@ -30,6 +36,9 @@ const ( FleetContextCapabilityPath = "/api/agent/fleet-context" // ResourceContextCapabilityPath is the manifest-owned per-resource context route. ResourceContextCapabilityPath = "/api/agent/resource-context/{" + ResourceIDArgumentName + "}" + // ListResourceCapabilitiesCapabilityPath is the manifest-owned per-resource + // capability surface route. + ListResourceCapabilitiesCapabilityPath = "/api/agent/resource-capabilities/{" + ResourceIDArgumentName + "}" // PatrolControlStatusCapabilityPath is the manifest-owned Patrol control // status route. PatrolControlStatusCapabilityPath = "/api/agent/patrol-control/status" diff --git a/internal/api/agent_activity_telemetry.go b/internal/api/agent_activity_telemetry.go index 8fd6d067f..bdec1c5b0 100644 --- a/internal/api/agent_activity_telemetry.go +++ b/internal/api/agent_activity_telemetry.go @@ -50,7 +50,8 @@ func externalAgentCapabilityActivity(capabilityName string) (string, string, boo } var activity string switch strings.TrimSpace(capabilityName) { - case agentcapabilities.ResourceContextCapabilityName: + case agentcapabilities.ResourceContextCapabilityName, + agentcapabilities.ListResourceCapabilitiesCapabilityName: activity = config.ExternalAgentActivityResourceContext case agentcapabilities.FleetContextCapabilityName: activity = config.ExternalAgentActivityFleetContext diff --git a/internal/api/agent_capabilities_test.go b/internal/api/agent_capabilities_test.go index b803279ec..b120f8047 100644 --- a/internal/api/agent_capabilities_test.go +++ b/internal/api/agent_capabilities_test.go @@ -202,9 +202,10 @@ func TestAgentCapabilitiesManifest_EveryCapabilityDeclaresMethodPathScope(t *tes func TestAgentCapabilitiesManifest_ScopesMatchAPIAuthConstants(t *testing.T) { expected := map[string]string{ - agentcapabilities.ResourceContextCapabilityName: config.ScopeMonitoringRead, - agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead, - agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead, + agentcapabilities.ResourceContextCapabilityName: config.ScopeMonitoringRead, + agentcapabilities.ListResourceCapabilitiesCapabilityName: config.ScopeMonitoringRead, + agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead, + agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead, "list_nodes": config.ScopeSettingsRead, "add_node": config.ScopeSettingsWrite, "update_node": config.ScopeSettingsWrite, diff --git a/internal/api/agent_resource_context.go b/internal/api/agent_resource_context.go index 91a6d656e..3e06dceb9 100644 --- a/internal/api/agent_resource_context.go +++ b/internal/api/agent_resource_context.go @@ -275,6 +275,17 @@ type AgentResourceContext struct { GeneratedAt time.Time `json:"generatedAt"` } +// AgentResourceCapabilities is the structured capability surface for a single +// resource: the names and parameter schemas an agent needs to populate +// plan_action.capabilityName and params. Mirrors Resource.Capabilities but is +// projected at request time so the wire shape stays stable. The internal +// execution handler is excluded via its `json:"-"` tag. +type AgentResourceCapabilities struct { + ResourceID string `json:"resourceId"` + Capabilities []unified.ResourceCapability `json:"capabilities"` + GeneratedAt time.Time `json:"generatedAt"` +} + // AgentFindingsProvider returns active findings as agent-stable snapshots and // count-only aggregate evidence. The implementation lives outside this package // (the patrol service holds the canonical findings store); this interface keeps @@ -579,6 +590,65 @@ func buildAgentResourceContextSections(resource unified.Resource, store unified. }) } +// HandleResourceCapabilities serves +// `GET /api/agent/resource-capabilities/{id}` — the structured +// capability surface for a single resource: the names and parameter +// schemas an agent reads before calling plan_action. Companion to the +// resource-context bundle, which renders capabilities as count-limited +// prose facts (omitting parameter schemas). A resource with no +// advertised capabilities returns 200 with an empty capabilities array, +// which is the signal an agent should not attempt plan_action against it. +// +// Computed at request time (no caching). The capability list is the +// in-memory Resource.Capabilities slice assembled by the registry, so +// this endpoint only reads the canonical source of truth that +// plan_action validates against. +func (h *AgentContextHandler) HandleResourceCapabilities(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if h.resources == nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + resourceID := extractAgentResourceCapabilitiesID(r.URL.Path) + if resourceID == "" { + http.Error(w, "Resource ID required", http.StatusBadRequest) + return + } + + orgID := GetOrgID(r.Context()) + registry, err := h.resources.buildRegistry(orgID) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + resource, resourceID, ok := presentationResourceByReference(registry, resourceID) + if !ok { + writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, + "No resource is registered with this canonical id.") + return + } + + // Capabilities default to a non-nil empty array so the wire shape is + // always a JSON array, never null. A resource advertising nothing is a + // valid, actionable answer (the signal to skip plan_action). + capabilities := []unified.ResourceCapability{} + if len(resource.Capabilities) > 0 { + capabilities = append(capabilities, resource.Capabilities...) + } + + bundle := AgentResourceCapabilities{ + ResourceID: resourceID, + Capabilities: capabilities, + GeneratedAt: time.Now().UTC(), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bundle) +} + func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *agentcontext.OperatorState { if state == nil { return nil @@ -604,6 +674,15 @@ func extractAgentResourceContextID(path string) string { return unified.CanonicalResourceID(trimmed) } +// extractAgentResourceCapabilitiesID parses the canonical resource ID out +// of the resource-capabilities URL path. Same trim/canonicalize pattern as +// extractAgentResourceContextID against the capabilities route prefix. +func extractAgentResourceCapabilitiesID(path string) string { + trimmed := strings.TrimPrefix(path, "/api/agent/resource-capabilities/") + trimmed = strings.TrimSuffix(trimmed, "/") + return unified.CanonicalResourceID(trimmed) +} + // HandleFleetContext serves // `GET /api/agent/fleet-context` — the agent-consumable triage view // across every resource visible to the org. Each entry is a thin diff --git a/internal/api/agent_resource_context_test.go b/internal/api/agent_resource_context_test.go index e6f31ca5c..33de047c7 100644 --- a/internal/api/agent_resource_context_test.go +++ b/internal/api/agent_resource_context_test.go @@ -751,6 +751,156 @@ func TestHandleAgentResourceContext_RejectsNonGet(t *testing.T) { } } +func TestHandleAgentResourceCapabilities_ReturnsStructuredCapabilitiesAndParams(t *testing.T) { + // Stage a resource advertising one capability with typed params. The + // response must surface the full param schema (what formatCapabilityFact + // deliberately omits in the resource-context bundle) so an agent can + // populate plan_action.params without guessing. The internal handler + // field must be absent from the JSON. + resource := unified.Resource{ + ID: "vm:101", + Type: "vm", + Name: "db-01", + Capabilities: []unified.ResourceCapability{ + { + Name: "restart", + Type: unified.CapabilityTypeNative, + Description: "Restart the guest workload.", + MinimumApprovalLevel: unified.ApprovalAdmin, + Platform: "qemu", + InternalHandler: "shouldNotAppearOnWire", + Params: []unified.CapabilityParam{ + { + Name: "force", + Type: "boolean", + Required: false, + Description: "Force a hard restart when the graceful stop times out.", + }, + { + Name: "timeout", + Type: "int", + Required: true, + Enum: []string{"30", "60", "120"}, + Pattern: `^\d+$`, + }, + }, + }, + }, + } + h, _ := agentContextFixtureHandlersForResource(t, resource) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default")) + h.HandleResourceCapabilities(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String()) + } + var bundle AgentResourceCapabilities + if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + if bundle.ResourceID != "vm:101" { + t.Errorf("resourceId: got %q want vm:101", bundle.ResourceID) + } + if len(bundle.Capabilities) != 1 { + t.Fatalf("capabilities: got %d want 1", len(bundle.Capabilities)) + } + cap := bundle.Capabilities[0] + if cap.Name != "restart" { + t.Errorf("capability name: got %q want restart", cap.Name) + } + if cap.Platform != "qemu" { + t.Errorf("platform: got %q want qemu", cap.Platform) + } + if cap.MinimumApprovalLevel != unified.ApprovalAdmin { + t.Errorf("approval level: got %q want admin", cap.MinimumApprovalLevel) + } + if len(cap.Params) != 2 { + t.Fatalf("params: got %d want 2", len(cap.Params)) + } + + // Param schemas must round-trip fully — this is the whole point of + // the new endpoint relative to the prose summary in get_resource_context. + force := cap.Params[0] + if force.Name != "force" || force.Type != "boolean" || force.Required { + t.Errorf("force param = %+v, want {force boolean false}", force) + } + if force.Description == "" { + t.Errorf("force param description must be preserved, got empty") + } + timeout := cap.Params[1] + if !timeout.Required || len(timeout.Enum) != 3 || timeout.Pattern == "" { + t.Errorf("timeout param = %+v, want required with enum+pattern", timeout) + } + + // Internal execution plumbing must never leak to the wire. + if raw := strings.TrimSpace(rec.Body.String()); strings.Contains(raw, "shouldNotAppearOnWire") { + t.Errorf("internal handler leaked to wire: %s", raw) + } +} + +func TestHandleAgentResourceCapabilities_EmptyCapabilitiesReturns200(t *testing.T) { + // A resource advertising nothing is the common case across the fleet + // and is exactly the signal an agent needs before attempting + // plan_action. It must be 200 with a JSON array, never null. + h, _ := agentContextFixtureHandlers(t, "vm:101") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default")) + h.HandleResourceCapabilities(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String()) + } + var bundle AgentResourceCapabilities + if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + if bundle.Capabilities == nil { + t.Fatal("capabilities is null; want non-nil empty array on the wire") + } + if len(bundle.Capabilities) != 0 { + t.Errorf("capabilities: got %d want 0", len(bundle.Capabilities)) + } + // Pin the wire shape directly: capabilities must serialize as []. + if !strings.Contains(rec.Body.String(), `"capabilities":[]`) { + t.Errorf("wire body must contain capabilities:[]; got %s", rec.Body.String()) + } +} + +func TestHandleAgentResourceCapabilities_404OnUnknownResource(t *testing.T) { + h, _ := agentContextFixtureHandlers(t, "vm:101") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:nonexistent", nil) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default")) + h.HandleResourceCapabilities(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404; got %d", rec.Code) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal error body: %v", err) + } + if body["error"] != agentcapabilities.AgentErrCodeResourceNotFound { + t.Errorf("expected error=%s; got %v", agentcapabilities.AgentErrCodeResourceNotFound, body) + } +} + +func TestHandleAgentResourceCapabilities_RejectsNonGet(t *testing.T) { + h, _ := agentContextFixtureHandlers(t, "vm:101") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/agent/resource-capabilities/vm:101", nil) + h.HandleResourceCapabilities(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405; got %d", rec.Code) + } +} + func TestHandleAgentResourceContext_RecentActionsCarryRefusalTokens(t *testing.T) { // Refused dispatches (resource_remediation_locked:, plan_drift:) // must surface verbatim in the recent-actions slice so agents can @@ -2605,3 +2755,20 @@ func TestExtractAgentResourceContextID(t *testing.T) { } } } + +func TestExtractAgentResourceCapabilitiesID(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"/api/agent/resource-capabilities/vm:101", "vm:101"}, + {"/api/agent/resource-capabilities/vm:101/", "vm:101"}, + {"/api/agent/resource-capabilities/instance:node:200", "instance:node:200"}, + {"/api/agent/resource-capabilities/", ""}, + } + for _, tc := range cases { + if got := extractAgentResourceCapabilitiesID(tc.path); got != tc.want { + t.Errorf("extractAgentResourceCapabilitiesID(%q) = %q, want %q", tc.path, got, tc.want) + } + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index bacf86ec5..565ba9b44 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -18628,6 +18628,54 @@ func TestContract_GetResourceContextCapabilityListsPendingApprovals(t *testing.T } } +// TestContract_ListResourceCapabilitiesCapabilitySurfacesStructuredParams pins +// the canonical agent-surface contract for action planning: agents must be able +// to discover a resource's governed capabilities and their parameter schemas +// through a dedicated structured tool, not only as the count-limited prose +// summary inside the resource-context bundle (formatCapabilityFact omits +// parameter schemas). Without this tool, agents guess plan_action inputs. +func TestContract_ListResourceCapabilitiesCapabilitySurfacesStructuredParams(t *testing.T) { + manifest := agentcapabilities.CanonicalManifest() + cap, ok := agentcapabilities.FindCapability(manifest.Capabilities, agentcapabilities.ListResourceCapabilitiesCapabilityName) + if !ok { + t.Fatalf("manifest missing %s", agentcapabilities.ListResourceCapabilitiesCapabilityName) + } + if cap.Method != http.MethodGet || cap.Scope != config.ScopeMonitoringRead { + t.Fatalf("%s must be a GET under monitoring:read so any read-only agent can plan actions; got method=%s scope=%s", + agentcapabilities.ListResourceCapabilitiesCapabilityName, cap.Method, cap.Scope) + } + if cap.Path != agentcapabilities.ListResourceCapabilitiesCapabilityPath { + t.Fatalf("%s path = %q, want %q", agentcapabilities.ListResourceCapabilitiesCapabilityName, cap.Path, agentcapabilities.ListResourceCapabilitiesCapabilityPath) + } + if len(cap.ErrorCodes) == 0 || cap.ErrorCodes[0] != agentcapabilities.AgentErrCodeResourceNotFound { + t.Fatalf("%s must declare resource_not_found so agents branch cleanly on unknown ids", agentcapabilities.ListResourceCapabilitiesCapabilityName) + } + // The capability must be projected to the MCP surface; otherwise external + // agents (the primary consumers of plan_action) cannot reach it. + found := false + for _, name := range agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP) { + if name.Name == agentcapabilities.ListResourceCapabilitiesCapabilityName { + found = true + break + } + } + if !found { + t.Fatalf("%s must be projected to the Pulse MCP surface", agentcapabilities.ListResourceCapabilitiesCapabilityName) + } + + // The handler must return a non-nil capabilities array (never null) so an + // agent can branch on len() without nil-checking — the same wire-shape rule + // the resource-context and fleet-context bundles follow. + handlerBytes, err := os.ReadFile("agent_resource_context.go") + if err != nil { + t.Fatalf("read agent_resource_context.go: %v", err) + } + handlerSrc := string(handlerBytes) + if !strings.Contains(handlerSrc, "capabilities := []unified.ResourceCapability{}") { + t.Errorf("%s handler must initialize capabilities as a non-nil empty array so the wire shape is always []", agentcapabilities.ListResourceCapabilitiesCapabilityName) + } +} + func TestContract_AgentCommandPayloadsRequireActionScope(t *testing.T) { source, err := os.ReadFile("agent_command_redaction.go") if err != nil { diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index d1c531350..d74454d95 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -424,6 +424,7 @@ var allRouteAllowlist = []string{ "/api/maintenance-verifications/{reportId}", "POST /api/maintenance-verifications/{reportId}/review", "/api/agent/resource-context/{id}", + "/api/agent/resource-capabilities/{id}", "/api/agent/fleet-context", "/api/agent/capabilities", "/api/agent/events", diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index a3e7a7256..961cb2c7d 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -89,6 +89,15 @@ func (r *Router) registerMonitoringResourceRoutes( agentcapabilities.ResourceContextCapabilityName, r.agentContextHandler.HandleResourceContext, )))) + // Agent-consumable structured capability surface for a single resource. + // Companion to the resource-context bundle above: that renders capabilities + // as count-limited prose facts; this returns the structured list (names + + // parameter schemas) an agent needs to populate plan_action.capabilityName + // and params without guessing. + r.mux.HandleFunc("/api/agent/resource-capabilities/{id}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity( + agentcapabilities.ListResourceCapabilitiesCapabilityName, + r.agentContextHandler.HandleResourceCapabilities, + )))) // Agent-consumable fleet triage view — thin per-resource rollups // across the org so an agent can pick a focus in one read instead // of walking every resource id and bundling each. Companion to the