Files
goclaw/internal/tools/delegate_async.go
T
1a42dc93a6 feat(teams): team system v2 with bug fixes, workspace scope, versioning, and prompt optimization (#183)
* feat(workspace): add team shared workspace for file collaboration

- Add workspace_write and workspace_read tools for agents to share files across team members
- Create team_workspaces DB table with migration 000017 (file metadata, pinning, tags)
- Implement PostgreSQL store layer for workspace CRUD operations
- Add RPC handlers for workspace list/read/delete from web UI
- Build React workspace tab with file listing, content preview, and delete
- Propagate workspace channel/chatID scope through delegation chain
- Auto-allow workspace tools in agent tool policy when agent belongs to a team
- Inject team workspace guidance into system prompt for team agents
- Add /reset command handler for clearing session history
- Harden MCP bridge context middleware to reject headers when no gateway token
- Add i18n strings for workspace UI in en/vi/zh locales

* feat(teams): add comprehensive task management with followup reminders and recovery

- Add task followup/reminder system with auto-set on lead agent reply and auto-clear when user responds on channel
- Add task recovery ticker to re-dispatch stale/pending tasks periodically
- Add task scopes, filtering by status/channel/chatID, and task events
- Add WS RPC handlers for task CRUD, assignments, comments, events, and bulk operations (teams_tasks.go)
- Add task detail dialog, settings UI for followup config, and scope filtering in web dashboard
- Add migrations 000018 (team_tasks_v2) and 000019 (task_followup)
- Extend team_tasks_tool with await_reply, clear_followup actions
- Auto-complete/fail team tasks when delegate agent finishes
- Add workspace file listing and team tool manager enhancements

* docs(teams): add team system architecture and playbook ideas documentation

- Add TEAM_SYSTEM.md with full architecture design covering task management, shared workspace, and delegation engine subsystems
- Add TEAM_PLAYBOOK_IDEAS.md outlining future team coordination layers (playbook, member capabilities, auto-learned patterns)
- Document data models, status flows, tool actions, followup reminder system, task ticker, execution locking, and workspace scope model

* fix(teams): resolve 6 critical bugs in team task system

- Fix unblock SQL: check array_length after array_remove (not before)
- Enforce single-team leadership in team creation
- Add requireLead() for approve/reject tool actions
- Validate cross-team dependency references in blocked_by
- Add team_id to handoff route for multi-team isolation
- Set blocked_by DEFAULT '{}' to prevent NULL array issues

* refactor(workspace): use stable userID as scope key instead of connection UUID

Workspace scope changed from (team_id, channel, chat_id) to (team_id, userID).
Fixes workspace fragmentation across WS tab refreshes and reconnections.

* feat(teams): add V1/V2 versioning with feature gating and optimized prompts

- IsTeamV2() helper gates advanced features (locking, followup, review, audit)
- V2 tool actions rejected for V1 teams with clear error message
- Ticker, gateway consumer, delegation hooks respect version flag
- TEAM.md renders v1/v2 sections conditionally
- Tool descriptions and params optimized (~38% token reduction)
- UI: version toggle in settings, V2 Beta badge, conditional rendering
- i18n: version modal keys for en/vi/zh

* fix(migration): use VARCHAR(255) for user ID columns and add metadata JSONB

- assignee_user_id, user_id, actor_id: TEXT → VARCHAR(255)
- Add metadata JSONB to team_task_comments and team_task_attachments

---------

Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
2026-03-13 22:41:32 +07:00

253 lines
9.0 KiB
Go

package tools
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// DelegateAsync spawns a delegation in the background and announces the result back.
func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) {
task, _, err := dm.prepareDelegation(ctx, opts, "async")
if err != nil {
return nil, err
}
taskCtx, taskCancel := context.WithCancel(context.Background())
task.cancelFunc = taskCancel
dm.active.Store(task.ID, task)
// Capture parent trace ID before goroutine (ctx.Background() loses it)
parentTraceID := tracing.TraceIDFromContext(ctx)
if parentTraceID != uuid.Nil {
taskCtx = tracing.WithDelegateParentTraceID(taskCtx, parentTraceID)
}
dm.injectDependencyResults(ctx, &opts)
dm.injectWorkspaceContext(ctx, task, &opts)
message := buildDelegateMessage(opts)
dm.emitDelegationEvent(protocol.EventDelegationStarted, task)
slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey)
runReq := dm.buildRunRequest(task, message)
go func() {
defer func() {
now := time.Now()
task.CompletedAt = &now
dm.active.Delete(task.ID)
}()
// Periodic progress notifications — tick every interval until runAgent returns
// or the delegation is cancelled. Listens on both progressDone (normal exit)
// and taskCtx.Done() (cancel/stopall) to avoid goroutine leaks.
progressDone := make(chan struct{})
go func() {
ticker := time.NewTicker(defaultProgressInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
dm.sendProgressNotification(task)
case <-progressDone:
return
case <-taskCtx.Done():
return
}
}
}()
startTime := time.Now()
result, runErr := dm.runAgent(taskCtx, opts.TargetAgentKey, runReq)
close(progressDone)
duration := time.Since(startTime)
// Count sibling delegations still running (exclude self).
// Scoped by origin (channel + chatID) so delegations from different
// conversations are NOT treated as siblings of each other.
oKey := task.originKey()
siblings := dm.ListActiveForOrigin(oKey)
siblingCount := 0
for _, s := range siblings {
if s.ID != task.ID {
siblingCount++
}
}
// Announce result to parent via message bus
if dm.msgBus != nil && task.OriginChannel != "" {
elapsed := time.Since(task.CreatedAt)
if siblingCount > 0 {
// Intermediate completion: accumulate artifacts + result summary.
// The final announce includes all sibling results so the lead doesn't
// need to call team_tasks to aggregate.
arts := &DelegateArtifacts{}
if result != nil {
arts.Media = result.Media
arts.Results = []DelegateResultSummary{{
AgentKey: task.TargetAgentKey,
DisplayName: task.TargetDisplayName,
Content: result.Content,
HasMedia: len(result.Media) > 0,
Deliverables: result.Deliverables,
}}
} else if runErr != nil {
arts.Results = []DelegateResultSummary{{
AgentKey: task.TargetAgentKey,
DisplayName: task.TargetDisplayName,
Content: fmt.Sprintf("[failed] %s", runErr.Error()),
}}
}
if task.TeamTaskID != uuid.Nil {
arts.CompletedTaskIDs = []string{task.TeamTaskID.String()}
}
dm.accumulateArtifacts(oKey, arts)
// Emit accumulated event so WS clients know this delegation finished
// but results are being held until siblings complete.
if dm.msgBus != nil {
dm.msgBus.Broadcast(bus.Event{
Name: protocol.EventDelegationAccumulated,
Payload: protocol.DelegationAccumulatedPayload{
DelegationID: task.ID,
SourceAgentID: task.SourceAgentID.String(),
SourceAgentKey: task.SourceAgentKey,
TargetAgentKey: task.TargetAgentKey,
TargetDisplayName: task.TargetDisplayName,
UserID: task.UserID,
Channel: task.OriginChannel,
ChatID: task.OriginChatID,
TeamID: func() string { if task.TeamID != uuid.Nil { return task.TeamID.String() }; return "" }(),
TeamTaskID: func() string { if task.TeamTaskID != uuid.Nil { return task.TeamTaskID.String() }; return "" }(),
SiblingsRemaining: siblingCount,
ElapsedMS: int(time.Since(task.CreatedAt).Milliseconds()),
},
})
}
slog.Info("delegation announce suppressed (siblings still running)",
"id", task.ID, "target", task.TargetAgentKey, "siblings", siblingCount)
} else {
// Last completion: clear progress dedup so next batch gets fresh notifications.
dm.progressSent.Delete(task.SourceAgentID.String() + ":" + task.OriginChatID)
// Last completion: collect all accumulated artifacts + own result
artifacts := dm.collectArtifacts(oKey)
if result != nil {
artifacts.Media = append(artifacts.Media, result.Media...)
artifacts.Results = append(artifacts.Results, DelegateResultSummary{
AgentKey: task.TargetAgentKey,
DisplayName: task.TargetDisplayName,
Content: result.Content,
HasMedia: len(result.Media) > 0,
Deliverables: result.Deliverables,
})
}
if task.TeamTaskID != uuid.Nil {
artifacts.CompletedTaskIDs = append(artifacts.CompletedTaskIDs, task.TeamTaskID.String())
}
announceMeta := map[string]string{
"origin_channel": task.OriginChannel,
"origin_peer_kind": task.OriginPeerKind,
"parent_agent": task.SourceAgentKey,
"delegation_id": task.ID,
"target_agent": task.TargetAgentKey,
"origin_trace_id": task.OriginTraceID.String(),
"origin_root_span_id": task.OriginRootSpanID.String(),
}
if task.OriginLocalKey != "" {
announceMeta["origin_local_key"] = task.OriginLocalKey
}
if task.OriginSessionKey != "" {
announceMeta["origin_session_key"] = task.OriginSessionKey
}
// Emit announce event so WS clients know all results are being sent to lead.
hasMedia := len(artifacts.Media) > 0
var announceSummaries []protocol.DelegationAnnounceResultSummary
for _, r := range artifacts.Results {
preview := r.Content
if runes := []rune(preview); len(runes) > 200 {
preview = string(runes[:200]) + "..."
}
announceSummaries = append(announceSummaries, protocol.DelegationAnnounceResultSummary{
AgentKey: r.AgentKey,
DisplayName: r.DisplayName,
HasMedia: r.HasMedia,
ContentPreview: preview,
})
if r.HasMedia {
hasMedia = true
}
}
dm.msgBus.Broadcast(bus.Event{
Name: protocol.EventDelegationAnnounce,
Payload: protocol.DelegationAnnouncePayload{
SourceAgentID: task.SourceAgentID.String(),
SourceAgentKey: task.SourceAgentKey,
SourceDisplayName: task.SourceDisplayName,
UserID: task.UserID,
Channel: task.OriginChannel,
ChatID: task.OriginChatID,
TeamID: func() string { if task.TeamID != uuid.Nil { return task.TeamID.String() }; return "" }(),
Results: announceSummaries,
CompletedTaskIDs: artifacts.CompletedTaskIDs,
TotalElapsedMS: int(elapsed.Milliseconds()),
HasMedia: hasMedia,
},
})
announceMsg := bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("delegate:%s", task.ID),
ChatID: task.OriginChatID,
Content: formatDelegateAnnounce(task, artifacts, runErr, elapsed),
UserID: task.UserID,
Metadata: announceMeta,
Media: artifacts.Media,
}
dm.msgBus.PublishInbound(announceMsg)
}
}
if runErr != nil {
task.Status = "failed"
dm.autoFailTeamTask(task, runErr.Error())
dm.emitDelegationEventWithError(task, runErr)
dm.saveDelegationHistory(task, "", runErr, duration)
} else {
// Apply quality gates before marking completed.
if result, runErr = dm.applyQualityGates(taskCtx, task, opts, result); runErr != nil {
task.Status = "failed"
dm.autoFailTeamTask(task, runErr.Error())
dm.emitDelegationEventWithError(task, runErr)
dm.saveDelegationHistory(task, "", runErr, duration)
} else {
task.Status = "completed"
dm.emitDelegationEvent(protocol.EventDelegationCompleted, task)
dm.trackCompleted(task)
resultContent := ""
var deliverables []string
if result != nil {
resultContent = result.Content
deliverables = result.Deliverables
}
// Auto-complete the team task for EVERY delegation (not just the last one).
// Each delegation has its own TeamTaskID — the isLastDelegation guard
// is for announce batching only, not for task completion.
dm.autoCompleteTeamTask(task, resultContent, deliverables)
dm.saveDelegationHistory(task, resultContent, nil, duration)
}
}
slog.Info("delegation finished (async)", "id", task.ID, "target", task.TargetAgentKey, "status", task.Status)
}()
return &DelegateResult{DelegationID: task.ID, TeamTaskID: task.TeamTaskID.String()}, nil
}