Files
synapbus/internal/agents/service.go
T
Algis DumbrisandClaude Opus 4.6 050b3cbde3 feat: A2A Agent Card discovery endpoint at /.well-known/agent-card.json
Add public A2A Agent Card endpoint that exposes registered agents as skills
for cross-platform agent discovery. Includes admin CLI for updating agent
capabilities, which populate skill tags and descriptions in the card.

- internal/a2a: new package with AgentCard generator, HTTP handler, and tests
- agents store/service: add ListAllActiveAgents (excludes human accounts)
- admin socket: add agent.update_capabilities command
- admin CLI: add `agent update-capabilities --name --capabilities` subcommand
- main.go: register /.well-known/agent-card.json route (public, no auth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:24:22 +02:00

393 lines
11 KiB
Go

package agents
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"golang.org/x/crypto/bcrypt"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
)
// AgentService provides business logic for agent registry operations.
type AgentService struct {
store AgentStore
tracer *trace.Tracer
deadLetterStore *messaging.DeadLetterStore
logger *slog.Logger
}
// NewAgentService creates a new agent service.
func NewAgentService(store AgentStore, tracer *trace.Tracer) *AgentService {
return &AgentService{
store: store,
tracer: tracer,
logger: slog.Default().With("component", "agents"),
}
}
// SetDeadLetterStore sets the dead letter store for capturing messages on agent deregistration.
func (s *AgentService) SetDeadLetterStore(dls *messaging.DeadLetterStore) {
s.deadLetterStore = dls
}
// Register creates a new agent with a generated API key.
// Returns the agent and the raw API key (shown once).
func (s *AgentService) Register(ctx context.Context, name, displayName, agentType string, capabilities json.RawMessage, ownerID int64) (*Agent, string, error) {
if name == "" {
return nil, "", fmt.Errorf("agent name is required")
}
if agentType == "" {
agentType = "ai"
}
if agentType != "ai" && agentType != "human" {
return nil, "", fmt.Errorf("agent type must be 'ai' or 'human'")
}
if capabilities == nil || len(capabilities) == 0 {
capabilities = json.RawMessage("{}")
}
if !json.Valid(capabilities) {
return nil, "", fmt.Errorf("capabilities must be valid JSON")
}
// Generate API key
apiKey, err := generateAPIKey()
if err != nil {
return nil, "", fmt.Errorf("generate API key: %w", err)
}
// Hash the key with bcrypt
hash, err := bcrypt.GenerateFromPassword([]byte(apiKey), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash API key: %w", err)
}
agent := &Agent{
Name: name,
DisplayName: displayName,
Type: agentType,
Capabilities: capabilities,
OwnerID: ownerID,
APIKeyHash: string(hash),
}
if err := s.store.CreateAgent(ctx, agent); err != nil {
return nil, "", fmt.Errorf("create agent: %w", err)
}
s.logger.Info("agent registered",
"name", name,
"type", agentType,
"owner_id", ownerID,
)
if s.tracer != nil {
s.tracer.Record(ctx, name, "register_agent", map[string]any{
"agent_id": agent.ID,
"agent_type": agentType,
"owner_id": ownerID,
})
}
return agent, apiKey, nil
}
// Authenticate verifies an API key and returns the associated agent.
func (s *AgentService) Authenticate(ctx context.Context, apiKey string) (*Agent, error) {
// Get all active agents and check the key against each.
// In production, this would use a prefix-based lookup or cache.
agents, err := s.store.ListActiveAgents(ctx)
if err != nil {
return nil, fmt.Errorf("list agents: %w", err)
}
for _, agent := range agents {
if err := bcrypt.CompareHashAndPassword([]byte(agent.APIKeyHash), []byte(apiKey)); err == nil {
return agent, nil
}
}
return nil, fmt.Errorf("invalid API key")
}
// GetAgent returns an agent by name.
func (s *AgentService) GetAgent(ctx context.Context, name string) (*Agent, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %s", name)
}
return nil, err
}
return agent, nil
}
// GetAgentByID returns an agent by ID.
func (s *AgentService) GetAgentByID(ctx context.Context, id int64) (*Agent, error) {
agent, err := s.store.GetAgentByID(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %d", id)
}
return nil, err
}
return agent, nil
}
// UpdateAgent updates an agent's display name and/or capabilities.
func (s *AgentService) UpdateAgent(ctx context.Context, name string, displayName string, capabilities json.RawMessage) (*Agent, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %s", name)
}
return nil, err
}
if displayName != "" {
agent.DisplayName = displayName
}
if capabilities != nil && len(capabilities) > 0 {
if !json.Valid(capabilities) {
return nil, fmt.Errorf("capabilities must be valid JSON")
}
agent.Capabilities = capabilities
}
if err := s.store.UpdateAgent(ctx, agent); err != nil {
return nil, fmt.Errorf("update agent: %w", err)
}
s.logger.Info("agent updated",
"name", name,
)
if s.tracer != nil {
s.tracer.Record(ctx, name, "update_agent", map[string]any{
"agent_id": agent.ID,
})
}
return agent, nil
}
// Deregister soft-deletes an agent. Only the owner can deregister.
// Pending/processing messages are captured as dead letters before deactivation.
func (s *AgentService) Deregister(ctx context.Context, name string, ownerID int64) error {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("agent not found: %s", name)
}
return err
}
if agent.OwnerID != ownerID {
return fmt.Errorf("only the agent's owner can deregister it")
}
// Capture pending/processing messages as dead letters before deactivation
var deadLetterCount int
if s.deadLetterStore != nil {
captured, err := s.deadLetterStore.CaptureDeadLetters(ctx, agent.OwnerID, name)
if err != nil {
s.logger.Warn("failed to capture dead letters",
"agent", name,
"error", err,
)
} else {
deadLetterCount = captured
}
}
if err := s.store.DeactivateAgent(ctx, name); err != nil {
return fmt.Errorf("deactivate agent: %w", err)
}
s.logger.Info("agent deregistered",
"name", name,
"owner_id", ownerID,
"dead_letters_captured", deadLetterCount,
)
if s.tracer != nil {
s.tracer.Record(ctx, name, "deregister_agent", map[string]any{
"agent_id": agent.ID,
"owner_id": ownerID,
"dead_letters_captured": deadLetterCount,
})
}
return nil
}
// DiscoverAgents searches for agents by capability keywords.
func (s *AgentService) DiscoverAgents(ctx context.Context, query string) ([]*Agent, error) {
if query == "" {
return s.store.ListActiveAgents(ctx)
}
return s.store.SearchAgentsByCapability(ctx, query)
}
// ListAgents returns all agents owned by the given owner.
func (s *AgentService) ListAgents(ctx context.Context, ownerID int64) ([]*Agent, error) {
return s.store.ListAgentsByOwner(ctx, ownerID)
}
// ListAllActiveAgents returns all active non-human agents across all owners.
// Used for the A2A Agent Card discovery endpoint.
func (s *AgentService) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) {
return s.store.ListAllActiveAgents(ctx)
}
// RevokeKey generates a new API key for an agent. Only the owner can do this.
// Returns the agent and the new raw API key (shown once).
func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64) (*Agent, string, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("agent not found: %s", name)
}
return nil, "", err
}
if agent.OwnerID != ownerID {
return nil, "", fmt.Errorf("only the agent's owner can revoke its API key")
}
// Generate new API key
apiKey, err := generateAPIKey()
if err != nil {
return nil, "", fmt.Errorf("generate API key: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(apiKey), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash API key: %w", err)
}
agent.APIKeyHash = string(hash)
if err := s.store.UpdateAgent(ctx, agent); err != nil {
return nil, "", fmt.Errorf("update agent: %w", err)
}
s.logger.Info("agent API key revoked",
"name", name,
"owner_id", ownerID,
)
if s.tracer != nil {
s.tracer.Record(ctx, name, "revoke_api_key", map[string]any{
"agent_id": agent.ID,
"owner_id": ownerID,
})
}
return agent, apiKey, nil
}
// GetHumanAgentForUser returns the human-type agent for a given owner.
// Returns nil, nil if no human agent exists for this user.
func (s *AgentService) GetHumanAgentForUser(ctx context.Context, ownerID int64) (*Agent, error) {
agent, err := s.store.GetHumanAgentByOwner(ctx, ownerID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get human agent: %w", err)
}
return agent, nil
}
// EnsureHumanAgent creates a human-type agent matching the username if one doesn't already exist.
// This is called on login to ensure every user has a human identity for sending messages from the UI.
func (s *AgentService) EnsureHumanAgent(ctx context.Context, username, displayName string, ownerID int64) (*Agent, error) {
// Check if user already has a human agent
agents, err := s.store.ListAgentsByOwner(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("list agents: %w", err)
}
for _, a := range agents {
if a.Type == "human" && a.Status == "active" {
return a, nil
}
}
// Create human agent matching the username
if displayName == "" {
displayName = username
}
// Try username first, then username-human if taken
agentName := username
agent, _, err := s.Register(ctx, agentName, displayName, "human", nil, ownerID)
if err != nil {
// Name collision — try with suffix
agentName = username + "-human"
agent, _, err = s.Register(ctx, agentName, displayName, "human", nil, ownerID)
if err != nil {
s.logger.Warn("could not auto-create human agent", "username", username, "error", err)
return nil, nil
}
}
s.logger.Info("auto-created human agent for user",
"username", username,
"agent_name", agent.Name,
"owner_id", ownerID,
)
return agent, nil
}
// EnsureSystemAgent creates the "system" agent if it doesn't already exist.
// The system agent is used for retention warnings and other system notifications.
func (s *AgentService) EnsureSystemAgent(ctx context.Context, ownerID int64) (*Agent, error) {
agent, err := s.store.GetAgentByName(ctx, "system")
if err == nil && agent != nil && agent.Status == "active" {
return agent, nil
}
agent, _, err = s.Register(ctx, "system", "System", "ai", json.RawMessage(`{"role":"system-notifications"}`), ownerID)
if err != nil {
// May already exist from a concurrent call
agent, err2 := s.store.GetAgentByName(ctx, "system")
if err2 == nil && agent != nil {
return agent, nil
}
return nil, fmt.Errorf("create system agent: %w", err)
}
s.logger.Info("created system agent", "owner_id", ownerID)
return agent, nil
}
// GetAgentWithOwner returns agent details along with the owner's display name.
func (s *AgentService) GetAgentWithOwner(ctx context.Context, name string) (*Agent, string, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
return nil, "", fmt.Errorf("get agent: %w", err)
}
return agent, "", nil // Owner name resolved by caller with access to user store
}
// generateAPIKey creates a cryptographically random API key (32 bytes, hex encoded).
func generateAPIKey() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}