Files
goclaw/internal/gateway/methods/teams.go
T
Viet Tran 9a9744077e refactor(teams): v2 system cleanup — remove legacy tools, fix followup, add events API (#210)
Major refactoring of the team system with multiple improvements:

## Removed legacy delegation tools
- Delete `delegate.go`, `delegate_async.go`, `delegate_sync.go`, `delegate_events.go`,
  `delegate_policy.go`, `delegate_prep.go`, `delegate_state.go`, `delegate_search_tool.go`
- Delete `evaluate_loop_tool.go`, `handoff_tool.go`
- Remove all references and registrations from tool manager and policy
- Clean up TEAM_PLAYBOOK_IDEAS.md and TEAM_SYSTEM.md (moved to docs)

## Rename await_reply → ask_user
- Rename action `await_reply` → `ask_user`, `clear_followup` → `clear_ask_user`
- Rename functions `executeAwaitReply` → `executeAskUser`, `executeClearFollowup` → `executeClearAskUser`
- Update system prompt with stronger wording to prevent model misuse
- Model was confusing "await_reply" with general waiting; "ask_user" is unambiguous

## Fix auto-followup false positives
- Add `HasActiveMemberTasks(ctx, teamID, excludeAgentID)` store method
- Guard `autoSetFollowup()` in consumer: skip when lead has active member tasks
- Prevents auto-followup when lead is orchestrating teammates (not waiting for user)

## Task identifier zero-padding
- Change format from `T-1-xxxx` → `T-001-xxxx` (3-digit minimum)

## Refactor workspace WS handlers to filesystem-only
- Rewrite `teams.workspace.list/read/delete` to use pure filesystem (os.ReadDir/ReadFile/Remove)
- Remove DB dependency from workspace WS handlers
- Consistent with storage handler and workspace tools
- Simplify TeamWorkspaceFile type and frontend hook

## Add team events listing API
- New WS method `teams.events.list` with team_id, limit, offset params
- New HTTP endpoint `GET /v1/teams/{id}/events` with bearer auth
- New `ListTeamEvents(ctx, teamID, limit, offset)` store method
- JOIN with team_tasks for team-wide event filtering

## Extract team access policy
- New `team_access_policy.go` — centralized team tool access control

## Migration 000019: team_id columns
- Add team_id foreign key columns to relevant tables

## Other improvements
- Add team_id propagation through agent loop, tracing, sessions
- Update i18n locale files (en/vi/zh) for new tool labels
- Update frontend builtin-tools page and require-setup component
- Bump RequiredSchemaVersion for migration 000019
2026-03-15 14:53:19 +07:00

203 lines
7.1 KiB
Go

package methods
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// TeamsMethods handles teams.* RPC methods.
type TeamsMethods struct {
teamStore store.TeamStore
agentStore store.AgentStore
linkStore store.AgentLinkStore // for auto-creating bidirectional links
agentRouter *agent.Router // for cache invalidation
msgBus *bus.MessageBus // for pub/sub cache invalidation
eventBus bus.EventPublisher
dataDir string // workspace data directory for resolving file paths
}
func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router, msgBus *bus.MessageBus, eventBus bus.EventPublisher, dataDir string) *TeamsMethods {
return &TeamsMethods{teamStore: teamStore, agentStore: agentStore, linkStore: linkStore, agentRouter: agentRouter, msgBus: msgBus, eventBus: eventBus, dataDir: dataDir}
}
// emitTeamCacheInvalidate broadcasts a cache invalidation event for team data.
func (m *TeamsMethods) emitTeamCacheInvalidate() {
if m.msgBus == nil {
return
}
m.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindTeam},
})
}
func (m *TeamsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodTeamsList, m.handleList)
router.Register(protocol.MethodTeamsCreate, m.handleCreate)
router.Register(protocol.MethodTeamsGet, m.handleGet)
router.Register(protocol.MethodTeamsDelete, m.handleDelete)
router.Register(protocol.MethodTeamsTaskList, m.handleTaskList)
router.Register(protocol.MethodTeamsTaskApprove, m.handleTaskApprove)
router.Register(protocol.MethodTeamsTaskReject, m.handleTaskReject)
router.Register(protocol.MethodTeamsMembersAdd, m.handleAddMember)
router.Register(protocol.MethodTeamsMembersRemove, m.handleRemoveMember)
router.Register(protocol.MethodTeamsUpdate, m.handleUpdate)
router.Register(protocol.MethodTeamsKnownUsers, m.handleKnownUsers)
router.Register(protocol.MethodTeamsScopes, m.handleScopes)
// Workspace handlers
m.RegisterWorkspace(router)
// Events handlers
router.Register(protocol.MethodTeamsEventsList, m.handleEventsList)
// Task detail handlers
m.RegisterTasks(router)
}
// --- List ---
func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
teams, err := m.teamStore.ListTeams(ctx)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"teams": teams,
"count": len(teams),
}))
}
// --- Create ---
type teamsCreateParams struct {
Name string `json:"name"`
Lead string `json:"lead"` // agent key or UUID
Members []string `json:"members"` // agent keys or UUIDs
Description string `json:"description"`
Settings json.RawMessage `json:"settings"`
}
func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
return
}
var params teamsCreateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if params.Lead == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead")))
return
}
// Resolve lead agent
leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error()))
return
}
// Enforce single-team leadership: an agent can only lead one team.
if existingTeam, _ := m.teamStore.GetTeamForAgent(ctx, leadAgent.ID); existingTeam != nil && existingTeam.LeadAgentID == leadAgent.ID {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
fmt.Sprintf("agent %q already leads team %q — each agent can only lead one team", params.Lead, existingTeam.Name)))
return
}
// Resolve member agents
var memberAgents []*store.AgentData
for _, memberKey := range params.Members {
ag, err := resolveAgentInfo(m.agentStore, memberKey)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error()))
return
}
memberAgents = append(memberAgents, ag)
}
// Create team
team := &store.TeamData{
Name: params.Name,
LeadAgentID: leadAgent.ID,
Description: params.Description,
Status: store.TeamStatusActive,
Settings: params.Settings,
CreatedBy: client.UserID(),
}
if err := m.teamStore.CreateTeam(ctx, team); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team", err.Error())))
return
}
// Add lead as member with lead role
if err := m.teamStore.AddMember(ctx, team.ID, leadAgent.ID, store.TeamRoleLead); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team lead membership", err.Error())))
return
}
// Add members
for _, ag := range memberAgents {
if ag.ID == leadAgent.ID {
continue // lead already added
}
if err := m.teamStore.AddMember(ctx, team.ID, ag.ID, store.TeamRoleMember); err != nil {
slog.Warn("teams.create: failed to add member", "agent", ag.AgentKey, "error", err)
}
}
// Auto-create outbound agent_links from lead to each member.
// Only the lead can delegate to members.
if m.linkStore != nil {
m.autoCreateTeamLinks(ctx, team.ID, leadAgent, memberAgents, client.UserID())
}
// Invalidate agent + team tool caches so TEAM.md gets injected
m.invalidateTeamCaches(ctx, team.ID)
emitAudit(m.eventBus, client, "team.created", "team", team.ID.String())
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"team": team,
}))
// Emit team.created event
if m.msgBus != nil {
m.msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamCreated,
Payload: protocol.TeamCreatedPayload{
TeamID: team.ID.String(),
TeamName: params.Name,
LeadAgentKey: leadAgent.AgentKey,
LeadDisplayName: leadAgent.DisplayName,
MemberCount: len(memberAgents) + 1,
},
})
}
}