mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
20c2f27057
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
244 lines
7.9 KiB
Go
244 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
func (r *Router) withExternalAgentActivity(activity string, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
r.recordExternalAgentActivity(req, activity)
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func (r *Router) withExternalAgentCapabilityActivity(capabilityName string, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
r.recordExternalAgentCapabilityActivity(req, capabilityName)
|
|
handler(w, req)
|
|
}
|
|
}
|
|
|
|
func (r *Router) recordExternalAgentCapabilityActivity(req *http.Request, capabilityName string) {
|
|
activity, requiredScope, ok := externalAgentCapabilityActivity(capabilityName)
|
|
if !ok {
|
|
return
|
|
}
|
|
r.recordExternalAgentActivity(req, activity, requiredScope)
|
|
}
|
|
|
|
func externalAgentActivityForCapability(capabilityName string) (string, bool) {
|
|
activity, _, ok := externalAgentCapabilityActivity(capabilityName)
|
|
return activity, ok
|
|
}
|
|
|
|
func externalAgentCapabilityActivity(capabilityName string) (string, string, bool) {
|
|
capabilityName = strings.TrimSpace(capabilityName)
|
|
if capabilityName == "" {
|
|
return "", "", false
|
|
}
|
|
capability, ok := agentcapabilities.FindCapability(agentcapabilities.CanonicalManifest().Capabilities, capabilityName)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
var activity string
|
|
switch strings.TrimSpace(capabilityName) {
|
|
case agentcapabilities.ResourceContextCapabilityName,
|
|
agentcapabilities.ListResourceCapabilitiesCapabilityName:
|
|
activity = config.ExternalAgentActivityResourceContext
|
|
case agentcapabilities.FleetContextCapabilityName:
|
|
activity = config.ExternalAgentActivityFleetContext
|
|
case agentcapabilities.OperationsLoopStatusCapabilityName:
|
|
activity = config.ExternalAgentActivityFleetContext
|
|
case agentcapabilities.EventSubscriptionCapabilityName:
|
|
activity = config.ExternalAgentActivityEventStream
|
|
case agentcapabilities.ListNodesCapabilityName,
|
|
agentcapabilities.AddNodeCapabilityName,
|
|
agentcapabilities.UpdateNodeCapabilityName,
|
|
agentcapabilities.RemoveNodeCapabilityName,
|
|
agentcapabilities.TestNodeCredentialsCapabilityName,
|
|
agentcapabilities.TestNodeConnectionCapabilityName,
|
|
agentcapabilities.RefreshNodeClusterMembershipCapabilityName,
|
|
agentcapabilities.DiscoverLANCapabilityName:
|
|
activity = config.ExternalAgentActivityProvisioning
|
|
case agentcapabilities.GetOperatorStateCapabilityName,
|
|
agentcapabilities.SetOperatorStateCapabilityName,
|
|
agentcapabilities.ClearOperatorStateCapabilityName:
|
|
activity = config.ExternalAgentActivityOperatorState
|
|
case agentcapabilities.ListFindingsCapabilityName:
|
|
activity = config.ExternalAgentActivityFindingList
|
|
case agentcapabilities.AcknowledgeFindingCapabilityName,
|
|
agentcapabilities.SnoozeFindingCapabilityName,
|
|
agentcapabilities.DismissFindingCapabilityName,
|
|
agentcapabilities.ResolveFindingCapabilityName:
|
|
activity = config.ExternalAgentActivityFindingDecision
|
|
case agentcapabilities.PlanActionCapabilityName:
|
|
activity = config.ExternalAgentActivityActionPlan
|
|
case agentcapabilities.DecideActionCapabilityName:
|
|
activity = config.ExternalAgentActivityActionDecision
|
|
case agentcapabilities.ExecuteActionCapabilityName:
|
|
activity = config.ExternalAgentActivityActionExecute
|
|
default:
|
|
return "", "", false
|
|
}
|
|
requiredScope := strings.TrimSpace(capability.Scope)
|
|
if requiredScope == "" {
|
|
return "", "", false
|
|
}
|
|
return activity, requiredScope, true
|
|
}
|
|
|
|
func (r *Router) recordExternalAgentActivity(req *http.Request, activity string, requiredScopes ...string) {
|
|
if r == nil || req == nil || strings.TrimSpace(activity) == "" {
|
|
return
|
|
}
|
|
token := getAPITokenRecordFromRequest(req)
|
|
if !apiTokenCoversExternalAgentSurface(token, time.Now().UTC(), requiredScopes...) {
|
|
return
|
|
}
|
|
persistence := r.persistenceForOrg(req.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
if err := persistence.RecordExternalAgentActivity(config.ExternalAgentActivityRecord{
|
|
Timestamp: time.Now().UTC(),
|
|
Surface: externalAgentActivitySurface(req),
|
|
Activity: activity,
|
|
}); err != nil {
|
|
log.Debug().Err(err).Str("activity", activity).Msg("Failed to record external agent activity")
|
|
}
|
|
}
|
|
|
|
type agentWorkflowPromptActivityRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// HandleAgentWorkflowPromptActivity records content-free workflow starter
|
|
// usage from agent adapters that render prompts locally, such as pulse-mcp.
|
|
func (r *Router) HandleAgentWorkflowPromptActivity(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var payload agentWorkflowPromptActivityRequest
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096))
|
|
if err := dec.Decode(&payload); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var discard json.RawMessage
|
|
if err := dec.Decode(&discard); err != io.EOF {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
promptName := strings.TrimSpace(payload.Name)
|
|
if !canonicalWorkflowPromptDeclared(promptName) {
|
|
http.Error(w, "Unknown workflow prompt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
r.recordWorkflowPromptActivity(req, workflowPromptActivitySurface(req), promptName)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func canonicalWorkflowPromptDeclared(promptName string) bool {
|
|
promptName = strings.TrimSpace(promptName)
|
|
if promptName == "" {
|
|
return false
|
|
}
|
|
for _, prompt := range agentcapabilities.ManifestPulseWorkflowPrompts(agentcapabilities.CanonicalManifest()) {
|
|
if strings.TrimSpace(prompt.Name) == promptName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *Router) recordWorkflowPromptActivity(req *http.Request, surface, promptName string) {
|
|
if r == nil || req == nil || strings.TrimSpace(promptName) == "" {
|
|
return
|
|
}
|
|
token := getAPITokenRecordFromRequest(req)
|
|
if !apiTokenCoversExternalAgentSurface(token, time.Now().UTC(), config.ScopeMonitoringRead) {
|
|
return
|
|
}
|
|
persistence := r.persistenceForOrg(req.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
if err := persistence.RecordWorkflowPromptActivity(config.WorkflowPromptActivityRecord{
|
|
Timestamp: time.Now().UTC(),
|
|
Surface: surface,
|
|
PromptName: promptName,
|
|
}); err != nil {
|
|
log.Debug().Err(err).Str("prompt_name", promptName).Msg("Failed to record workflow prompt activity")
|
|
}
|
|
}
|
|
|
|
func workflowPromptActivitySurface(req *http.Request) string {
|
|
if req == nil {
|
|
return config.WorkflowPromptActivitySurfaceAgentAPI
|
|
}
|
|
switch strings.TrimSpace(req.Header.Get(agentcapabilities.AgentSurfaceHeader)) {
|
|
case agentcapabilities.AgentSurfacePulseMCP:
|
|
return config.WorkflowPromptActivitySurfacePulseMCP
|
|
default:
|
|
return config.WorkflowPromptActivitySurfaceAgentAPI
|
|
}
|
|
}
|
|
|
|
func externalAgentActivitySurface(req *http.Request) string {
|
|
if req == nil {
|
|
return config.ExternalAgentActivitySurfaceAgentAPI
|
|
}
|
|
switch strings.TrimSpace(req.Header.Get(agentcapabilities.AgentSurfaceHeader)) {
|
|
case agentcapabilities.AgentSurfacePulseMCP:
|
|
return config.ExternalAgentActivitySurfacePulseMCP
|
|
default:
|
|
return config.ExternalAgentActivitySurfaceAgentAPI
|
|
}
|
|
}
|
|
|
|
func apiTokenCoversExternalAgentSurface(token *config.APITokenRecord, now time.Time, requiredScopes ...string) bool {
|
|
if token == nil {
|
|
return false
|
|
}
|
|
if token.ExpiresAt != nil && now.After(token.ExpiresAt.UTC()) {
|
|
return false
|
|
}
|
|
if len(requiredScopes) == 0 {
|
|
requiredScopes = agentcapabilities.CanonicalManifest().RequiredScopes
|
|
}
|
|
if len(requiredScopes) == 0 {
|
|
return false
|
|
}
|
|
token = cloneAPITokenRecord(token)
|
|
for _, scope := range requiredScopes {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
return false
|
|
}
|
|
if !token.HasScope(scope) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cloneAPITokenRecord(token *config.APITokenRecord) *config.APITokenRecord {
|
|
if token == nil {
|
|
return nil
|
|
}
|
|
clone := token.Clone()
|
|
return &clone
|
|
}
|