mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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
This commit is contained in:
parent
b1fdefdd50
commit
20c2f27057
15 changed files with 404 additions and 19 deletions
|
|
@ -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:
|
|||
|
||||
<!-- pulse-mcp-errors:start -->
|
||||
- `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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue