mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 20:10:59 +00:00
9a9744077e
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
203 lines
7.1 KiB
Go
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, ¶ms); 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,
|
|
},
|
|
})
|
|
}
|
|
}
|