diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index 7ef4f5ad..8d7b47b1 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -31,6 +31,8 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsGet, m.handleGet) router.Register(protocol.MethodTeamsDelete, m.handleDelete) router.Register(protocol.MethodTeamsTaskList, m.handleTaskList) + router.Register(protocol.MethodTeamsMembersAdd, m.handleAddMember) + router.Register(protocol.MethodTeamsMembersRemove, m.handleRemoveMember) } // --- List --- @@ -294,6 +296,172 @@ func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, })) } +// --- Add Member --- + +type teamsAddMemberParams struct { + TeamID string `json:"teamId"` + Agent string `json:"agent"` // agent key or UUID +} + +func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsAddMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.Agent == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Validate team exists + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + + // Resolve agent + ag, err := resolveAgentInfo(m.agentStore, params.Agent) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) + return + } + + // Prevent adding lead again + if ag.ID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead")) + return + } + + // Add member + if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error())) + return + } + + // Auto-create bidirectional links between new member and existing members + if m.linkStore != nil { + existingMembers, _ := m.teamStore.ListMembers(ctx, teamID) + for _, member := range existingMembers { + if member.AgentID == ag.ID { + continue + } + memberAgent, err := m.agentStore.GetByID(ctx, member.AgentID) + if err != nil { + continue + } + m.autoCreateTeamLinks(ctx, teamID, []*store.AgentData{ag, memberAgent}, client.UserID()) + } + } + + // Invalidate caches for all team members + m.invalidateTeamCaches(ctx, teamID) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Remove Member --- + +type teamsRemoveMemberParams struct { + TeamID string `json:"teamId"` + AgentID string `json:"agentId"` // agent UUID +} + +func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsRemoveMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + agentID, err := uuid.Parse(params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId")) + return + } + + ctx := context.Background() + + // Validate team exists and prevent removing the lead + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + if agentID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead")) + return + } + + // Remove member + if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error())) + return + } + + // Clean up team-specific links + if m.linkStore != nil { + if err := m.linkStore.DeleteTeamLinksForAgent(ctx, teamID, agentID); err != nil { + slog.Warn("teams.members.remove: failed to clean up links", "error", err) + } + } + + // Invalidate caches for all remaining members + removed agent + m.invalidateTeamCaches(ctx, teamID) + if m.agentRouter != nil { + ag, err := m.agentStore.GetByID(ctx, agentID) + if err == nil { + m.agentRouter.InvalidateAgent(ag.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// invalidateTeamCaches invalidates agent caches for all members of a team. +func (m *TeamsMethods) invalidateTeamCaches(ctx context.Context, teamID uuid.UUID) { + if m.agentRouter == nil { + return + } + members, err := m.teamStore.ListMembers(ctx, teamID) + if err != nil { + return + } + for _, member := range members { + if member.AgentKey != "" { + m.agentRouter.InvalidateAgent(member.AgentKey) + } + } +} + // --- helpers --- // autoCreateTeamLinks creates bidirectional agent_links between all team members. diff --git a/internal/store/agent_link_store.go b/internal/store/agent_link_store.go index 814695c8..7bc4cb73 100644 --- a/internal/store/agent_link_store.go +++ b/internal/store/agent_link_store.go @@ -71,4 +71,7 @@ type AgentLinkStore interface { // SearchDelegateTargetsByEmbedding performs vector similarity search over delegation targets. SearchDelegateTargetsByEmbedding(ctx context.Context, fromAgentID uuid.UUID, embedding []float32, limit int) ([]AgentLinkData, error) + + // DeleteTeamLinksForAgent removes all team-specific links involving an agent. + DeleteTeamLinksForAgent(ctx context.Context, teamID, agentID uuid.UUID) error } diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go index 4710d116..44343d08 100644 --- a/internal/store/pg/agent_links.go +++ b/internal/store/pg/agent_links.go @@ -246,6 +246,14 @@ func (s *PGAgentLinkStore) SearchDelegateTargetsByEmbedding(ctx context.Context, return scanLinkRowsJoined(rows) } +func (s *PGAgentLinkStore) DeleteTeamLinksForAgent(ctx context.Context, teamID, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM agent_links WHERE team_id = $1 AND (source_agent_id = $2 OR target_agent_id = $2)`, + teamID, agentID, + ) + return err +} + // --- scan helpers --- func scanLinkRow(row *sql.Row) (*store.AgentLinkData, error) { diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 03e59bcf..ce03c85d 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -103,7 +103,9 @@ const ( MethodTeamsCreate = "teams.create" MethodTeamsGet = "teams.get" MethodTeamsDelete = "teams.delete" - MethodTeamsTaskList = "teams.tasks.list" + MethodTeamsTaskList = "teams.tasks.list" + MethodTeamsMembersAdd = "teams.members.add" + MethodTeamsMembersRemove = "teams.members.remove" ) // Delegation history (managed mode) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index f3fc9151..5e4a8305 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -129,6 +129,8 @@ export const Methods = { TEAMS_GET: "teams.get", TEAMS_DELETE: "teams.delete", TEAMS_TASK_LIST: "teams.tasks.list", + TEAMS_MEMBERS_ADD: "teams.members.add", + TEAMS_MEMBERS_REMOVE: "teams.members.remove", // Delegation history (managed mode) DELEGATIONS_LIST: "delegations.list", diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index 7f058967..d0ab6ad7 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -87,7 +87,7 @@ export function Combobox({ /> )} {open && filtered.length > 0 && ( -