aa9d9fd5f1
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
913 lines
31 KiB
Go
913 lines
31 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
agentusage "github.com/kandev/kandev/internal/agent/usage"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/office/configloader"
|
|
"github.com/kandev/kandev/internal/office/models"
|
|
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/office/routing"
|
|
"github.com/kandev/kandev/internal/office/shared"
|
|
officeskills "github.com/kandev/kandev/internal/office/skills"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// UsageProvider fetches subscription utilization for an agent profile.
|
|
// The provider is optional — if nil, utilization is always nil.
|
|
type UsageProvider interface {
|
|
GetUsage(ctx context.Context, profileID string) (*agentusage.ProviderUsage, error)
|
|
}
|
|
|
|
// ProfileStore owns the complete canonical agent profile shape. Office's
|
|
// repository intentionally projects only Office fields, so profile-selection
|
|
// writes use this store to preserve advanced runtime configuration.
|
|
type ProfileStore interface {
|
|
GetAgentProfile(ctx context.Context, id string) (*models.AgentInstance, error)
|
|
UpdateAgentProfile(ctx context.Context, profile *models.AgentInstance) error
|
|
}
|
|
|
|
// Sentinel errors for agent validation.
|
|
var (
|
|
ErrAgentNameRequired = errors.New("agent name is required")
|
|
ErrAgentRoleInvalid = errors.New("invalid agent role")
|
|
ErrAgentCEOAlreadyExists = errors.New("workspace already has a CEO agent")
|
|
ErrAgentCEOReportsTo = errors.New("CEO agent must be the workspace root")
|
|
ErrAgentReportsToInvalid = errors.New("reports_to agent does not exist in this workspace")
|
|
ErrAgentReportsToSelf = errors.New("agent cannot report to itself")
|
|
ErrAgentReportsToCycle = errors.New("agent reporting structure cannot contain a cycle")
|
|
ErrAgentStatusTransition = errors.New("invalid status transition")
|
|
)
|
|
|
|
// GovernanceSettingsReader reads workspace governance settings.
|
|
type GovernanceSettingsReader interface {
|
|
GetRequireApprovalForNewAgents(ctx context.Context, workspaceID string) (bool, error)
|
|
}
|
|
|
|
// GovernanceApprovalCreator creates approval records for governance-gated actions.
|
|
type GovernanceApprovalCreator interface {
|
|
CreateApprovalWithActivity(ctx context.Context, approval *models.Approval) error
|
|
}
|
|
|
|
// SessionTerminator cascades termination of an agent's office task sessions
|
|
// when the agent instance is deleted at the workspace level. Optional —
|
|
// when nil, deletion proceeds without flipping any session rows (legacy).
|
|
type SessionTerminator interface {
|
|
TerminateAllForAgent(ctx context.Context, agentInstanceID, reason string) error
|
|
}
|
|
|
|
// CoordinatorRoutineInstaller installs the pre-baked coordinator-heartbeat
|
|
// routine for a freshly created coordinator agent. Optional — when nil
|
|
// the agent-create path skips routine install (e.g. tests that don't
|
|
// construct the routines service).
|
|
type CoordinatorRoutineInstaller interface {
|
|
CreateDefaultCoordinatorRoutine(ctx context.Context, workspaceID, agentID string) (*models.Routine, error)
|
|
}
|
|
|
|
var validRoles = map[models.AgentRole]bool{
|
|
models.AgentRoleCEO: true,
|
|
models.AgentRoleWorker: true,
|
|
models.AgentRoleSpecialist: true,
|
|
models.AgentRoleAssistant: true,
|
|
models.AgentRoleSecurity: true,
|
|
models.AgentRoleQA: true,
|
|
models.AgentRoleDevOps: true,
|
|
}
|
|
|
|
// allowedTransitions defines which status transitions are valid.
|
|
var allowedTransitions = map[models.AgentStatus][]models.AgentStatus{
|
|
models.AgentStatusIdle: {models.AgentStatusWorking, models.AgentStatusPaused, models.AgentStatusStopped, models.AgentStatusPendingApproval},
|
|
models.AgentStatusWorking: {models.AgentStatusIdle, models.AgentStatusPaused, models.AgentStatusStopped},
|
|
models.AgentStatusPaused: {models.AgentStatusIdle, models.AgentStatusStopped},
|
|
models.AgentStatusStopped: {models.AgentStatusIdle},
|
|
models.AgentStatusPendingApproval: {models.AgentStatusIdle, models.AgentStatusStopped},
|
|
}
|
|
|
|
// AgentListFilter specifies optional filters for listing agents.
|
|
type AgentListFilter struct {
|
|
Role string
|
|
Status string
|
|
ReportsTo string
|
|
}
|
|
|
|
// AgentService provides agent instance CRUD, status management, and related operations.
|
|
type AgentService struct {
|
|
repo *sqlite.Repository
|
|
logger *logger.Logger
|
|
activity shared.ActivityLogger
|
|
auth *AgentAuth
|
|
cfgWriter *configloader.FileWriter
|
|
usageProvider UsageProvider
|
|
governanceSettings GovernanceSettingsReader
|
|
governanceApproval GovernanceApprovalCreator
|
|
sessionTerm SessionTerminator
|
|
routineInstaller CoordinatorRoutineInstaller
|
|
knownProvidersFn func() []routing.ProviderID
|
|
profileStore ProfileStore
|
|
}
|
|
|
|
// SetProfileStore wires the canonical settings repository used when an Office
|
|
// agent copies a CLI profile's complete runtime configuration.
|
|
func (s *AgentService) SetProfileStore(store ProfileStore) {
|
|
s.profileStore = store
|
|
}
|
|
|
|
// SetKnownProvidersFn wires the source of routing-eligible provider IDs
|
|
// used by PATCH /agents/:id when validating an AgentOverrides blob.
|
|
// Optional; when unset, the handler falls back to routing.KnownProviders(nil)
|
|
// which returns the static v1 allow-list.
|
|
func (s *AgentService) SetKnownProvidersFn(fn func() []routing.ProviderID) {
|
|
s.knownProvidersFn = fn
|
|
}
|
|
|
|
// KnownProviders returns the routing-eligible provider IDs the agents
|
|
// service uses for override validation.
|
|
func (s *AgentService) KnownProviders() []routing.ProviderID {
|
|
if s.knownProvidersFn != nil {
|
|
return s.knownProvidersFn()
|
|
}
|
|
return routing.KnownProviders(nil)
|
|
}
|
|
|
|
// GetWorkspaceRouting returns the workspace's routing config so the
|
|
// handler can cross-check an agent's overrides against the workspace
|
|
// tier map at save time. Delegates to the office repo's defaults-aware
|
|
// reader, so callers never need to special-case the "never configured"
|
|
// workspace.
|
|
func (s *AgentService) GetWorkspaceRouting(
|
|
ctx context.Context, workspaceID string,
|
|
) (*routing.WorkspaceConfig, error) {
|
|
return s.repo.GetWorkspaceRouting(ctx, workspaceID)
|
|
}
|
|
|
|
// SetCoordinatorRoutineInstaller wires the routines-service hook used
|
|
// to install the default "Coordinator heartbeat" routine for a newly
|
|
// created coordinator agent. Called by the app composition layer after
|
|
// both services are constructed.
|
|
func (s *AgentService) SetCoordinatorRoutineInstaller(i CoordinatorRoutineInstaller) {
|
|
s.routineInstaller = i
|
|
}
|
|
|
|
// SetSessionTerminator wires the office session terminator. Called by the
|
|
// app composition layer after both services are constructed (the orchestrator
|
|
// owns the terminator implementation; the agents service consumes it).
|
|
func (s *AgentService) SetSessionTerminator(t SessionTerminator) {
|
|
s.sessionTerm = t
|
|
}
|
|
|
|
// NewAgentService creates a new AgentService.
|
|
func NewAgentService(repo *sqlite.Repository, log *logger.Logger, activity shared.ActivityLogger) *AgentService {
|
|
return &AgentService{
|
|
repo: repo,
|
|
logger: log.WithFields(zap.String("component", "office-agents")),
|
|
activity: activity,
|
|
}
|
|
}
|
|
|
|
// SetUsageProvider wires in a subscription usage provider. Optional — if not set,
|
|
// utilization is always nil in responses.
|
|
func (s *AgentService) SetUsageProvider(p UsageProvider) {
|
|
s.usageProvider = p
|
|
}
|
|
|
|
// SetGovernanceSettings wires in a governance settings reader for approval checks.
|
|
func (s *AgentService) SetGovernanceSettings(g GovernanceSettingsReader) {
|
|
s.governanceSettings = g
|
|
}
|
|
|
|
// SetGovernanceApproval wires in an approval creator for governance-gated agent creation.
|
|
func (s *AgentService) SetGovernanceApproval(g GovernanceApprovalCreator) {
|
|
s.governanceApproval = g
|
|
}
|
|
|
|
// GetAgentUtilization returns the current subscription utilization for the agent,
|
|
// or nil if the agent is not a subscription agent or no usage provider is configured.
|
|
func (s *AgentService) GetAgentUtilization(ctx context.Context, agentInstanceID string) (*agentusage.ProviderUsage, error) {
|
|
if s.usageProvider == nil {
|
|
return nil, nil
|
|
}
|
|
agent, err := s.GetAgentFromConfig(ctx, agentInstanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Wave G: agent_profiles.id IS the profile id, exposed via AgentInstance.ID.
|
|
if agent.ID == "" {
|
|
return nil, nil
|
|
}
|
|
return s.usageProvider.GetUsage(ctx, agent.ID)
|
|
}
|
|
|
|
// GetAgentInstance looks up an agent by ID or name.
|
|
// Implements shared.AgentReader.
|
|
func (s *AgentService) GetAgentInstance(ctx context.Context, idOrName string) (*models.AgentInstance, error) {
|
|
return s.GetAgentFromConfig(ctx, idOrName)
|
|
}
|
|
|
|
// ListAgentInstances returns all agent instances for a workspace.
|
|
// Implements shared.AgentReader.
|
|
func (s *AgentService) ListAgentInstances(ctx context.Context, wsID string) ([]*models.AgentInstance, error) {
|
|
return s.ListAgentsFromConfig(ctx, wsID)
|
|
}
|
|
|
|
// ListAgentInstancesByIDs returns agent instances whose ids are in `ids`.
|
|
// Implements shared.AgentReader.
|
|
func (s *AgentService) ListAgentInstancesByIDs(ctx context.Context, ids []string) ([]*models.AgentInstance, error) {
|
|
return s.repo.ListAgentInstancesByIDs(ctx, ids)
|
|
}
|
|
|
|
// UpdateAgentStatusFields persists a new status and optional pause reason for an agent.
|
|
// Implements shared.AgentWriter.
|
|
func (s *AgentService) UpdateAgentStatusFields(ctx context.Context, agentID, status, pauseReason string) error {
|
|
return s.repo.UpdateAgentStatusFields(ctx, agentID, status, pauseReason)
|
|
}
|
|
|
|
// GetAgentFromConfig looks up an agent by ID or name.
|
|
func (s *AgentService) GetAgentFromConfig(ctx context.Context, idOrName string) (*models.AgentInstance, error) {
|
|
if agent, err := s.repo.GetAgentInstance(ctx, idOrName); err == nil {
|
|
return agent, nil
|
|
}
|
|
agent, err := s.repo.GetAgentInstanceByNameAny(ctx, idOrName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent not found: %s", idOrName)
|
|
}
|
|
return agent, nil
|
|
}
|
|
|
|
// getAgentFromConfigInWorkspace resolves an ID or name without crossing a
|
|
// workspace boundary. IDs are globally unique, while names are workspace-scoped.
|
|
func (s *AgentService) getAgentFromConfigInWorkspace(
|
|
ctx context.Context, idOrName, workspaceID string,
|
|
) (*models.AgentInstance, error) {
|
|
if agent, err := s.repo.GetAgentInstance(ctx, idOrName); err == nil {
|
|
if agent.WorkspaceID != workspaceID {
|
|
return nil, ErrAgentReportsToInvalid
|
|
}
|
|
return agent, nil
|
|
}
|
|
return s.repo.GetAgentInstanceByName(ctx, workspaceID, idOrName)
|
|
}
|
|
|
|
// ListAgentsFromConfig returns all agent instances for a workspace.
|
|
// An empty workspaceID returns rows across all workspaces.
|
|
func (s *AgentService) ListAgentsFromConfig(ctx context.Context, workspaceID string) ([]*models.AgentInstance, error) {
|
|
return s.repo.ListAgentInstances(ctx, workspaceID)
|
|
}
|
|
|
|
// CreateAgentInstance validates and creates a new agent instance in the DB.
|
|
func (s *AgentService) CreateAgentInstance(ctx context.Context, agent *models.AgentInstance) error {
|
|
if err := s.validateAgentCreate(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
s.prepareAgentDefaults(agent)
|
|
s.seedDefaultSkills(ctx, agent)
|
|
return s.persistAgent(ctx, agent)
|
|
}
|
|
|
|
// CreateAgentInstanceWithCaller validates governance and creates a new agent instance.
|
|
// If governance requires approval for new agents, it creates a hire_agent approval record
|
|
// after persisting the new instance in pending_approval status.
|
|
func (s *AgentService) CreateAgentInstanceWithCaller(
|
|
ctx context.Context,
|
|
agent *models.AgentInstance,
|
|
callerAgent *models.AgentInstance,
|
|
reason string,
|
|
) error {
|
|
if err := s.validateAgentCreate(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
inheritCallerExecutorPreference(agent, callerAgent)
|
|
s.prepareAgentDefaults(agent)
|
|
s.seedDefaultSkills(ctx, agent)
|
|
|
|
if s.requiresHireApproval(ctx, agent.WorkspaceID, callerAgent) {
|
|
agent.Status = models.AgentStatusPendingApproval
|
|
if err := s.persistAgent(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
return s.createHireApproval(ctx, agent, callerAgent, reason)
|
|
}
|
|
return s.persistAgent(ctx, agent)
|
|
}
|
|
|
|
func inheritCallerExecutorPreference(
|
|
agent *models.AgentInstance,
|
|
callerAgent *models.AgentInstance,
|
|
) {
|
|
if agent == nil || callerAgent == nil {
|
|
return
|
|
}
|
|
if !executorPreferenceEmpty(agent.ExecutorPreference) {
|
|
return
|
|
}
|
|
if executorPreferenceEmpty(callerAgent.ExecutorPreference) {
|
|
return
|
|
}
|
|
agent.ExecutorPreference = callerAgent.ExecutorPreference
|
|
}
|
|
|
|
func executorPreferenceEmpty(raw string) bool {
|
|
switch strings.TrimSpace(raw) {
|
|
case "", "{}", "null":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *AgentService) prepareAgentDefaults(agent *models.AgentInstance) {
|
|
if agent.ID == "" {
|
|
agent.ID = uuid.New().String()
|
|
}
|
|
if agent.Permissions == "" || agent.Permissions == "{}" {
|
|
agent.Permissions = shared.DefaultPermissions(shared.AgentRole(agent.Role))
|
|
}
|
|
if agent.MaxConcurrentSessions < 1 {
|
|
agent.MaxConcurrentSessions = 1
|
|
}
|
|
if agent.CooldownSec <= 0 {
|
|
agent.CooldownSec = 10
|
|
}
|
|
if agent.DesiredSkills == "" {
|
|
agent.DesiredSkills = "[]"
|
|
}
|
|
if agent.ExecutorPreference == "" {
|
|
agent.ExecutorPreference = "{}"
|
|
}
|
|
if agent.Status == "" {
|
|
agent.Status = models.AgentStatusIdle
|
|
}
|
|
// Office names are user-facing identities, not generated profile labels.
|
|
agent.UserModified = true
|
|
}
|
|
|
|
// BackfillDefaultSkillsForWorkspace seeds default system skills onto
|
|
// every existing agent in `workspaceID` whose `desired_skills` is
|
|
// currently empty (`""` / `"[]"`). Use case: an agent that pre-dates
|
|
// the system-skill rollout boots with no skills attached even though
|
|
// matching defaults exist. Runs once at startup per workspace
|
|
// (called from the office service init path), and again whenever
|
|
// `SyncSystemSkills` reports inserts / removes for a workspace.
|
|
//
|
|
// Agents with any populated skill list are left alone — that array
|
|
// represents the user's curated choice, including the deliberate
|
|
// choice of "no system skills".
|
|
func (s *AgentService) BackfillDefaultSkillsForWorkspace(ctx context.Context, workspaceID string) {
|
|
if workspaceID == "" {
|
|
return
|
|
}
|
|
// Ensure bundled system skills are present in this workspace before
|
|
// reading them. A workspace created after backend startup (e.g. via
|
|
// onboarding) skips the boot-time sync; the agent backfill must not
|
|
// silently noop on it.
|
|
if _, err := officeskills.SyncSystemSkills(
|
|
ctx, s.repo, []string{workspaceID}, nil, s.logger,
|
|
); err != nil {
|
|
s.logger.Warn("backfill default skills: ensure system skills",
|
|
zap.String("workspace_id", workspaceID), zap.Error(err))
|
|
}
|
|
systemSkills, err := s.repo.ListSystemSkills(ctx, workspaceID)
|
|
if err != nil {
|
|
s.logger.Warn("backfill default skills: list system skills",
|
|
zap.String("workspace_id", workspaceID), zap.Error(err))
|
|
return
|
|
}
|
|
if len(systemSkills) == 0 {
|
|
return
|
|
}
|
|
agents, err := s.repo.ListAgentInstances(ctx, workspaceID)
|
|
if err != nil {
|
|
s.logger.Warn("backfill default skills: list agents",
|
|
zap.String("workspace_id", workspaceID), zap.Error(err))
|
|
return
|
|
}
|
|
var updated int
|
|
for _, agent := range agents {
|
|
if !needsDefaultSkillSeed(agent.DesiredSkills) {
|
|
continue
|
|
}
|
|
picks := pickDefaultSkillsForRole(systemSkills, string(agent.Role))
|
|
if len(picks.IDs) == 0 {
|
|
continue
|
|
}
|
|
if err := applyDefaultSkillPicks(agent, picks); err != nil {
|
|
s.logger.Warn("backfill default skills: encode ids", zap.Error(err))
|
|
continue
|
|
}
|
|
if err := s.repo.UpdateAgentInstance(ctx, agent); err != nil {
|
|
s.logger.Warn("backfill default skills: persist",
|
|
zap.String("agent_id", agent.ID), zap.Error(err))
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
if updated > 0 {
|
|
s.logger.Info("backfilled default skills",
|
|
zap.String("workspace_id", workspaceID),
|
|
zap.Int("agents_updated", updated))
|
|
}
|
|
}
|
|
|
|
// seedDefaultSkills auto-attaches every system skill whose
|
|
// `default_for_roles` includes this agent's role. Runs only when
|
|
// the caller hasn't already populated DesiredSkills (so explicit
|
|
// API requests with their own skill set are respected). Failures
|
|
// are warn-logged: a fresh workspace might not have system skills
|
|
// synced yet, but missing defaults shouldn't block agent creation.
|
|
func (s *AgentService) seedDefaultSkills(ctx context.Context, agent *models.AgentInstance) {
|
|
if !needsDefaultSkillSeed(agent.DesiredSkills) {
|
|
return
|
|
}
|
|
// Lazy sync mirrors the SkillService.ListSkillsFromConfig path so
|
|
// onboarding-created workspaces — which the boot-time sync hasn't
|
|
// touched — still surface the bundled set before we read it.
|
|
if _, err := officeskills.SyncSystemSkills(
|
|
ctx, s.repo, []string{agent.WorkspaceID}, nil, s.logger,
|
|
); err != nil {
|
|
s.logger.Warn("seed default skills: ensure system skills",
|
|
zap.String("workspace_id", agent.WorkspaceID), zap.Error(err))
|
|
}
|
|
systemSkills, err := s.repo.ListSystemSkills(ctx, agent.WorkspaceID)
|
|
if err != nil {
|
|
s.logger.Warn("list system skills for default seed",
|
|
zap.String("workspace_id", agent.WorkspaceID), zap.Error(err))
|
|
return
|
|
}
|
|
picks := pickDefaultSkillsForRole(systemSkills, string(agent.Role))
|
|
if len(picks.IDs) == 0 {
|
|
return
|
|
}
|
|
if err := applyDefaultSkillPicks(agent, picks); err != nil {
|
|
s.logger.Warn("encode default skill ids", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// defaultSkillPicks bundles the parallel id+slug lists for the
|
|
// system skills auto-attached to an agent. SkillIDs feeds the
|
|
// UI's per-agent toggle (it compares against `skill.id`); the
|
|
// legacy DesiredSkills slug list is what the runtime materializer
|
|
// resolves at launch time. Writing both keeps the two surfaces in
|
|
// sync — the alternative left the UI showing "no skills" even
|
|
// when the agent was already attached to the bundled set.
|
|
type defaultSkillPicks struct {
|
|
IDs []string
|
|
Slugs []string
|
|
}
|
|
|
|
func applyDefaultSkillPicks(agent *models.AgentInstance, picks defaultSkillPicks) error {
|
|
encodedIDs, err := json.Marshal(picks.IDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encodedSlugs, err := json.Marshal(picks.Slugs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
agent.SkillIDs = string(encodedIDs)
|
|
agent.DesiredSkills = string(encodedSlugs)
|
|
return nil
|
|
}
|
|
|
|
// needsDefaultSkillSeed returns true when DesiredSkills is empty or
|
|
// the JSON sentinel `[]`. Any other value means the caller picked
|
|
// an explicit set and we leave it alone.
|
|
func needsDefaultSkillSeed(desired string) bool {
|
|
trimmed := strings.TrimSpace(desired)
|
|
return trimmed == "" || trimmed == "[]"
|
|
}
|
|
|
|
// pickDefaultSkillsForRole returns the parallel (id, slug) lists for
|
|
// every system skill whose `default_for_roles` JSON array contains
|
|
// the given role. Skills without the column populated (legacy rows)
|
|
// or that fail to parse are skipped silently.
|
|
func pickDefaultSkillsForRole(skills []*models.Skill, role string) defaultSkillPicks {
|
|
picks := defaultSkillPicks{}
|
|
if role == "" {
|
|
return picks
|
|
}
|
|
for _, sk := range skills {
|
|
if !sk.IsSystem || sk.DefaultForRoles == "" {
|
|
continue
|
|
}
|
|
var roles []string
|
|
if err := json.Unmarshal([]byte(sk.DefaultForRoles), &roles); err != nil {
|
|
continue
|
|
}
|
|
for _, r := range roles {
|
|
if r == role {
|
|
picks.IDs = append(picks.IDs, sk.ID)
|
|
picks.Slugs = append(picks.Slugs, sk.Slug)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return picks
|
|
}
|
|
|
|
func (s *AgentService) persistAgent(ctx context.Context, agent *models.AgentInstance) error {
|
|
if err := s.repo.CreateAgentInstance(ctx, agent); err != nil {
|
|
return fmt.Errorf("create agent: %w", err)
|
|
}
|
|
if s.profileStore != nil {
|
|
if err := s.profileStore.UpdateAgentProfile(ctx, agent); err != nil {
|
|
if rollbackErr := s.repo.DeleteAgentInstance(ctx, agent.ID); rollbackErr != nil {
|
|
return fmt.Errorf(
|
|
"persist agent runtime configuration: %w (rollback failed: %v)",
|
|
err, rollbackErr,
|
|
)
|
|
}
|
|
return fmt.Errorf("persist agent runtime configuration: %w", err)
|
|
}
|
|
}
|
|
if err := s.CreateDefaultInstructions(ctx, agent.ID, string(agent.Role)); err != nil {
|
|
s.logger.Warn("failed to create default instructions", zap.Error(err))
|
|
}
|
|
s.installCoordinatorRoutine(ctx, agent)
|
|
return nil
|
|
}
|
|
|
|
// ApplyProfileConfiguration assigns the legacy provider-family foreign key
|
|
// needed by agent_profiles. Runtime configuration is intentionally not copied:
|
|
// Office launches resolve their complete execution profile through routing.
|
|
func (s *AgentService) ApplyProfileConfiguration(
|
|
ctx context.Context,
|
|
target *models.AgentInstance,
|
|
sourceProfileID string,
|
|
) error {
|
|
if s.profileStore == nil {
|
|
return errors.New("agent profile store is not configured")
|
|
}
|
|
source, err := s.profileStore.GetAgentProfile(ctx, sourceProfileID)
|
|
if err != nil {
|
|
return fmt.Errorf("get source agent profile: %w", err)
|
|
}
|
|
if source.WorkspaceID != "" && source.WorkspaceID != target.WorkspaceID {
|
|
return errors.New("source agent profile belongs to a different workspace")
|
|
}
|
|
target.AgentID = source.AgentID
|
|
return nil
|
|
}
|
|
|
|
// installCoordinatorRoutine triggers the default coordinator-heartbeat
|
|
// routine install for CEO/coordinator agents. Pending-approval agents
|
|
// skip the install — their routine is materialised once the approval
|
|
// flips them to idle (a future iteration; for now they fire on demand
|
|
// via comments / mentions). Failures are warn-logged and ignored.
|
|
func (s *AgentService) installCoordinatorRoutine(ctx context.Context, agent *models.AgentInstance) {
|
|
if s.routineInstaller == nil || agent == nil {
|
|
return
|
|
}
|
|
if agent.Role != models.AgentRoleCEO {
|
|
return
|
|
}
|
|
if agent.Status == models.AgentStatusPendingApproval {
|
|
return
|
|
}
|
|
if _, err := s.routineInstaller.CreateDefaultCoordinatorRoutine(ctx, agent.WorkspaceID, agent.ID); err != nil {
|
|
s.logger.Warn("install default coordinator routine",
|
|
zap.String("workspace_id", agent.WorkspaceID),
|
|
zap.String("agent_id", agent.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (s *AgentService) requiresHireApproval(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
callerAgent *models.AgentInstance,
|
|
) bool {
|
|
if callerAgent == nil || s.governanceSettings == nil || s.governanceApproval == nil {
|
|
return false
|
|
}
|
|
required, err := s.governanceSettings.GetRequireApprovalForNewAgents(ctx, workspaceID)
|
|
return err == nil && required
|
|
}
|
|
|
|
func (s *AgentService) createHireApproval(
|
|
ctx context.Context,
|
|
agent *models.AgentInstance,
|
|
callerAgent *models.AgentInstance,
|
|
reason string,
|
|
) error {
|
|
b, err := json.Marshal(map[string]interface{}{
|
|
"agent_profile_id": agent.ID,
|
|
"creator_agent_id": callerAgent.ID,
|
|
"creator_agent_name": callerAgent.Name,
|
|
"name": agent.Name,
|
|
"role": string(agent.Role),
|
|
"permissions": agent.Permissions,
|
|
"reason": reason,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal hire approval payload: %w", err)
|
|
}
|
|
approval := &models.Approval{
|
|
WorkspaceID: agent.WorkspaceID,
|
|
Type: models.ApprovalTypeHireAgent,
|
|
RequestedByAgentProfileID: callerAgent.ID,
|
|
Payload: string(b),
|
|
}
|
|
if err := s.governanceApproval.CreateApprovalWithActivity(ctx, approval); err != nil {
|
|
return fmt.Errorf("create hire_agent approval: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AgentService) ListAgentInstancesFiltered(
|
|
ctx context.Context, workspaceID string, filter AgentListFilter,
|
|
) ([]*models.AgentInstance, error) {
|
|
return s.repo.ListAgentInstancesFiltered(ctx, workspaceID, sqlite.AgentListFilter{
|
|
Role: filter.Role,
|
|
Status: filter.Status,
|
|
ReportsTo: filter.ReportsTo,
|
|
})
|
|
}
|
|
|
|
// UpdateAgentInstance validates and updates an existing agent instance in the DB.
|
|
func (s *AgentService) UpdateAgentInstance(ctx context.Context, agent *models.AgentInstance) error {
|
|
if err := s.validateAgentUpdate(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.UpdateAgentInstance(ctx, agent); err != nil {
|
|
return fmt.Errorf("update agent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAgentStatus validates a status transition and persists the new state to the DB.
|
|
func (s *AgentService) UpdateAgentStatus(
|
|
ctx context.Context, id string, newStatus models.AgentStatus, pauseReason string,
|
|
) (*models.AgentInstance, error) {
|
|
agent, err := s.GetAgentFromConfig(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateStatusTransition(agent.Status, newStatus); err != nil {
|
|
return nil, err
|
|
}
|
|
if dbErr := s.repo.UpdateAgentStatusFields(ctx, agent.ID, string(newStatus), pauseReason); dbErr != nil {
|
|
return nil, fmt.Errorf("persist agent status: %w", dbErr)
|
|
}
|
|
agent.Status = newStatus
|
|
agent.PauseReason = pauseReason
|
|
return agent, nil
|
|
}
|
|
|
|
// DeleteAgentInstance deletes an agent instance from the DB and cascades
|
|
// termination of every office task session belonging to that agent. The
|
|
// cascade runs after the DB row is gone so a concurrent EnsureSessionForAgent
|
|
// can't resurrect the row by inserting a fresh CREATED state.
|
|
func (s *AgentService) DeleteAgentInstance(ctx context.Context, id string) error {
|
|
agent, err := s.GetAgentFromConfig(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.DeleteAgentInstance(ctx, agent.ID); err != nil {
|
|
return fmt.Errorf("delete agent: %w", err)
|
|
}
|
|
if s.sessionTerm != nil {
|
|
if err := s.sessionTerm.TerminateAllForAgent(ctx, agent.ID, "agent_instance_deleted"); err != nil {
|
|
s.logger.Warn("cascade-terminate office sessions on agent deletion failed",
|
|
zap.String("agent_profile_id", agent.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultPermissionsJSON returns the default permissions JSON for a role.
|
|
func DefaultPermissionsJSON(role models.AgentRole) string {
|
|
perms := defaultPermsForRole(role)
|
|
b, _ := json.Marshal(perms)
|
|
return string(b)
|
|
}
|
|
|
|
func defaultPermsForRole(role models.AgentRole) map[string]interface{} {
|
|
switch role {
|
|
case models.AgentRoleCEO:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": true,
|
|
"can_create_agents": true,
|
|
"can_create_projects": true,
|
|
"can_approve": true,
|
|
"can_manage_own_skills": true,
|
|
"max_subtask_depth": 3,
|
|
}
|
|
case models.AgentRoleAssistant:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": true,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": false,
|
|
"can_manage_own_skills": true,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
case models.AgentRoleWorker:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": true,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": false,
|
|
"can_manage_own_skills": false,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
case models.AgentRoleSecurity:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": false,
|
|
"can_assign_tasks": true,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": true,
|
|
"can_manage_own_skills": true,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
case models.AgentRoleQA:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": false,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": false,
|
|
"can_manage_own_skills": true,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
case models.AgentRoleDevOps:
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": false,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": false,
|
|
"can_manage_own_skills": true,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
default: // specialist and any unknown roles
|
|
return map[string]interface{}{
|
|
"can_create_tasks": true,
|
|
"can_assign_tasks": false,
|
|
"can_create_agents": false,
|
|
"can_create_projects": false,
|
|
"can_approve": false,
|
|
"can_manage_own_skills": false,
|
|
"max_subtask_depth": 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateAgentCreate checks all business rules for creating an agent.
|
|
func (s *AgentService) validateAgentCreate(ctx context.Context, agent *models.AgentInstance) error {
|
|
if agent.Name == "" {
|
|
return ErrAgentNameRequired
|
|
}
|
|
if !validRoles[agent.Role] {
|
|
return ErrAgentRoleInvalid
|
|
}
|
|
if agent.Role == models.AgentRoleCEO {
|
|
if agent.ReportsTo != "" {
|
|
return ErrAgentCEOReportsTo
|
|
}
|
|
if s.countAgentsByRoleInWorkspace(ctx, models.AgentRoleCEO, agent.WorkspaceID, "") > 0 {
|
|
return ErrAgentCEOAlreadyExists
|
|
}
|
|
}
|
|
if err := s.validateAgentNameUnique(ctx, agent.Name, agent.WorkspaceID, ""); err != nil {
|
|
return err
|
|
}
|
|
if agent.ReportsTo != "" {
|
|
return s.validateReportsTo(ctx, agent)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateAgentUpdate checks business rules for updating an agent.
|
|
func (s *AgentService) validateAgentUpdate(ctx context.Context, agent *models.AgentInstance) error {
|
|
if agent.Name == "" {
|
|
return ErrAgentNameRequired
|
|
}
|
|
if !validRoles[agent.Role] {
|
|
return ErrAgentRoleInvalid
|
|
}
|
|
if agent.Role == models.AgentRoleCEO {
|
|
if agent.ReportsTo != "" {
|
|
return ErrAgentCEOReportsTo
|
|
}
|
|
if s.countAgentsByRoleInWorkspace(ctx, models.AgentRoleCEO, agent.WorkspaceID, agent.ID) > 0 {
|
|
return ErrAgentCEOAlreadyExists
|
|
}
|
|
}
|
|
if agent.ReportsTo != "" {
|
|
return s.validateReportsTo(ctx, agent)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// countAgentsByRoleInWorkspace counts agents with a role within a workspace,
|
|
// optionally excluding one ID. workspaceID must be non-empty to scope the query.
|
|
func (s *AgentService) countAgentsByRoleInWorkspace(
|
|
ctx context.Context, role models.AgentRole, workspaceID, excludeID string,
|
|
) int {
|
|
count, err := s.repo.CountAgentInstancesByRole(ctx, workspaceID, string(role), excludeID)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return count
|
|
}
|
|
|
|
// validateAgentNameUnique ensures no other agent in the workspace has the same name.
|
|
func (s *AgentService) validateAgentNameUnique(ctx context.Context, name, workspaceID, excludeID string) error {
|
|
exists, err := s.repo.AgentInstanceExistsByName(ctx, workspaceID, name, excludeID)
|
|
if err != nil {
|
|
return fmt.Errorf("check agent name uniqueness: %w", err)
|
|
}
|
|
if exists {
|
|
return fmt.Errorf("agent name %q already exists", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateReportsTo ensures the target agent exists in the same workspace and
|
|
// that changing the edge does not create a cycle in the reporting tree. On
|
|
// success it normalizes agent.ReportsTo to the target's ID, so a caller that
|
|
// names its manager rather than IDing it persists the canonical ID like every
|
|
// other reference — not the raw name, which would otherwise mismatch a
|
|
// same-named agent in another workspace on the next read.
|
|
func (s *AgentService) validateReportsTo(ctx context.Context, agent *models.AgentInstance) error {
|
|
selfID := agent.ID
|
|
target, err := s.getAgentFromConfigInWorkspace(ctx, agent.ReportsTo, agent.WorkspaceID)
|
|
if err != nil {
|
|
return ErrAgentReportsToInvalid
|
|
}
|
|
if selfID != "" && target.ID == selfID {
|
|
return ErrAgentReportsToSelf
|
|
}
|
|
agent.ReportsTo = target.ID
|
|
if selfID == "" {
|
|
return nil
|
|
}
|
|
|
|
agents, err := s.ListAgentsFromConfig(ctx, agent.WorkspaceID)
|
|
if err != nil {
|
|
return ErrAgentReportsToInvalid
|
|
}
|
|
parentByID := make(map[string]string, len(agents))
|
|
idByReference := make(map[string]string, len(agents)*2)
|
|
for _, a := range agents {
|
|
parentByID[a.ID] = a.ReportsTo
|
|
idByReference[a.ID] = a.ID
|
|
if a.Name != "" {
|
|
idByReference[a.Name] = a.ID
|
|
}
|
|
}
|
|
// Validate the proposed edge, not the stale value currently stored for the
|
|
// agent being updated.
|
|
parentByID[selfID] = target.ID
|
|
|
|
currentID := target.ID
|
|
visited := make(map[string]struct{}, len(agents))
|
|
for currentID != "" {
|
|
if currentID == selfID {
|
|
return ErrAgentReportsToCycle
|
|
}
|
|
if _, seen := visited[currentID]; seen {
|
|
return ErrAgentReportsToCycle
|
|
}
|
|
visited[currentID] = struct{}{}
|
|
parentReference := parentByID[currentID]
|
|
if parentReference == "" {
|
|
return nil
|
|
}
|
|
nextID, ok := idByReference[parentReference]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
currentID = nextID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateStatusTransition checks if a status transition is allowed.
|
|
func validateStatusTransition(from, to models.AgentStatus) error {
|
|
if from == to {
|
|
return nil
|
|
}
|
|
allowed, ok := allowedTransitions[from]
|
|
if !ok {
|
|
return fmt.Errorf("%w: unknown current status %q", ErrAgentStatusTransition, from)
|
|
}
|
|
for _, s := range allowed {
|
|
if s == to {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%w: cannot transition from %q to %q", ErrAgentStatusTransition, from, to)
|
|
}
|