Files
synapbus/internal/agents/service.go
T
Algis DumbrisandClaude Opus 4.6 b8a70bfe66 feat(harness): Option C — subprocess agent config (CLAUDE.md, MCP, skills)
Makes the subprocess backend fully self-contained: each agent carries
its instructions, MCP servers, skills, and subagents in its
harness_config_json column, viewable in the Web UI, editable via CLI.

internal/harness/subprocess/config.go (NEW):

  AgentConfig struct with optional fields:
    - claude_md       → workdir/CLAUDE.md
    - agents_md       → workdir/AGENTS.md
    - mcp_servers     → workdir/.mcp.json (Claude Code format)
    - skills          → workdir/.claude/skills/<name>/SKILL.md
    - subagents       → workdir/.claude/agents/<name>.md
    - env             → layered into child env (after k8s_env_json,
                        before caller overrides)

  ParseAgentConfig  tolerates empty / returns error on invalid JSON.
  MaterialiseAgentConfig writes all artifacts into the workdir with
  path-traversal sanitisation on skill/subagent names.

subprocess.Harness.Execute now calls Parse + Materialise before exec,
so an agent's declarative config is on disk by the time the child
CLI's cwd lookup fires. buildEnv takes the parsed config and overlays
cfg.Env on top of k8s_env_json.

Tests:
  config_test.go — 6 cases: empty, invalid JSON, full round-trip,
    materialise writes all artefacts, empty is a no-op, skill names
    are sanitised against "../escape" / "/etc/passwd", mcp entries
    without a name are dropped.
  subprocess_test.go — 2 new e2e cases: agent with CLAUDE.md + mcp
    servers + skills + env sees all of them from inside the child via
    cat/echo; invalid harness_config_json surfaces as Execute error.

internal/agents/store.go:

  AgentStore gains UpdateHarnessConfig(ctx, name, harnessName,
  localCommand, harnessConfigJSON). Empty strings leave a field
  unchanged; literal "-" clears (sets to NULL). Returns sql.ErrNoRows
  on missing agent. AgentService exposes Store() so admin handlers
  can reach it without adding a full service method for a
  config-set-style operation.

  store_test.go: 6-subcase test covers set-all, partial update, clear,
  unknown agent, and no-field no-op.

internal/admin/socket.go:

  Two new admin commands:
    harness.config_get {agent_name} → {harness_name, local_command,
        harness_config_json, harness_config (parsed), parse_error?}
    harness.config_set {agent_name, harness_name?, local_command?,
        harness_config_json?} → updated fields
  config_set validates JSON shape before calling the store; null /
  "-" literals clear the column.

cmd/synapbus/admin.go:

  New top-level `harness config` command group:
    synapbus harness config get --agent <name> [--raw]
    synapbus harness config set --agent <name>
        [--harness-name subprocess]
        [--local-command '["claude","--print"]']
        [--file config.json]    # or pipe from stdin
        [--clear]
    synapbus harness config edit --agent <name>
        # fetches current config, opens $VISUAL/$EDITOR/vi,
        # validates JSON on save, writes back via config_set

web/src/routes/agents/[name]/+page.svelte:

  New read-only "Harness" panel on the agent detail page:
    - Resolved backend badge (explicit or inferred from k8s_image /
      local_command / harness_config_json.url)
    - Grid summary: CLAUDE.md size, AGENTS.md size, MCP server count,
      skills count
    - Collapsible details for CLAUDE.md, AGENTS.md, each MCP server
      (name / type / url|command / header count), skill filenames,
      subagent filenames, env vars
    - Footer hint showing the CLI edit command
  No edit controls — editing is CLI-only by design (safer, fits an
  ops-heavy workflow).

Verified: full project test suite (40+ packages including integration
tests) plus `vite build` of the Svelte app all green; `go vet ./...`
clean; the existing TestSubprocess_Execute_MaterialisesHarnessConfig
e2e test proves the round-trip from harness_config_json → workdir →
child process works end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:57:28 +03:00

400 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
}
// Store returns the underlying AgentStore. Exposed so admin handlers
// can reach store-level helpers (e.g. UpdateHarnessConfig) that don't
// warrant full service-level business logic of their own.
func (s *AgentService) Store() AgentStore {
return s.store
}
// 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
}