Files
goclaw/internal/tools/team_tasks_followup.go
T
viettranx 78253f1841 refactor: decompose consumer, agent loop, team tools, and MCP manager
6-phase pure refactor — zero behavior change, all tests pass.

Phase 1: DRY event payloads + metadata keys
- BuildTaskEventPayload() helper with functional options replaces 24 inline constructions
- 26 metadata string constants replace ~66 magic strings across dispatch/consumer

Phase 2: Consumer handler decomposition
- ConsumerDeps struct collapses 11-13 positional params to 3 per handler
- Extract startTaskLockRenewal() and resolveTeamTaskOutcome()

Phase 3: loop.go extraction (978→686 LOC)
- enrichInputMedia(): media persistence, ref collection, tag enrichment
- injectTeamTaskReminders(): leader/member task context injection
- buildFilteredTools(): policy, bootstrap, channel, iteration filtering
- collectRefsByKind() helper eliminates 3x copy-paste

Phase 4: team_tasks_mutations split (630→318 LOC)
- executeCreate extracted to team_tasks_create.go
- resolveTeamAndTask() helper used by 13 execute* functions

Phase 5: MCP LoadForAgent split (~90→~15 LOC body)
- resolveServerCredentials() + connectAndFilter() helpers

Phase 6: Comments + DI
- 5 inaccurate comments fixed
- ToolExecutor interface for dependency inversion (Loop.tools field)
- FilterTools accepts ToolExecutor instead of concrete *Registry
2026-03-28 17:25:59 +07:00

143 lines
5.3 KiB
Go

package tools
import (
"context"
"fmt"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
followupMessage, _ := args["text"].(string)
if followupMessage == "" {
return ErrorResult("text is required for ask_user action (the question for the user)")
}
// Verify ownership.
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
return ErrorResult("only the task owner can set follow-up reminders")
}
// Resolve delay and max from team settings.
delayMinutes := t.manager.FollowupDelayMinutes(team)
maxReminders := t.manager.FollowupMaxReminders(team)
// Resolve channel: prefer task's channel, fallback to context channel.
channel := task.Channel
chatID := task.ChatID
ctxChannel := ToolChannelFromCtx(ctx)
if channel == "" || channel == ChannelTeammate || channel == ChannelSystem || channel == ChannelDashboard {
channel = ctxChannel
chatID = ToolChatIDFromCtx(ctx)
}
if channel == "" || channel == ChannelTeammate || channel == ChannelSystem || channel == ChannelDashboard {
return ErrorResult("cannot set follow-up: no valid channel found (task has no origin channel and context channel is internal)")
}
followupAt := time.Now().Add(time.Duration(delayMinutes) * time.Minute)
if err := t.manager.Store().SetTaskFollowup(ctx, taskID, team.ID, followupAt, maxReminders, followupMessage, channel, chatID); err != nil {
return ErrorResult("failed to set follow-up: " + err.Error())
}
maxDesc := "unlimited"
if maxReminders > 0 {
maxDesc = fmt.Sprintf("max %d", maxReminders)
}
return NewResult(fmt.Sprintf("Follow-up set for task %s. First reminder in %d minutes via %s (%s).", taskID, delayMinutes, channel, maxDesc))
}
func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify task belongs to team.
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Allow owner or lead to clear.
if task.OwnerAgentID == nil || (*task.OwnerAgentID != agentID && agentID != team.LeadAgentID) {
return ErrorResult("only the task owner or team lead can clear follow-up reminders")
}
if err := t.manager.Store().ClearTaskFollowup(ctx, taskID); err != nil {
return ErrorResult("failed to clear follow-up: " + err.Error())
}
return NewResult(fmt.Sprintf("Follow-up reminders cleared for task %s.", taskID))
}
func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *Result {
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
switch task.Status {
case store.TeamTaskStatusStale, store.TeamTaskStatusFailed, store.TeamTaskStatusCompleted:
// OK — can retry/reopen these statuses
default:
return ErrorResult(fmt.Sprintf("retry only works on completed, stale, or failed tasks (current status: %s)", task.Status))
}
if task.OwnerAgentID == nil {
return ErrorResult("task has no assignee — assign it first via update")
}
// Block retry to the lead agent — would cause self-dispatch loop.
if *task.OwnerAgentID == team.LeadAgentID {
return ErrorResult("cannot retry task assigned to the team lead — reassign to a team member first via update")
}
// Reset status to pending first (AssignTask only transitions from pending).
if err := t.manager.Store().ResetTaskStatus(ctx, taskID, team.ID); err != nil {
return ErrorResult("failed to reset task: " + err.Error())
}
// Assign (pending → in_progress + lock).
if err := t.manager.Store().AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
return ErrorResult("failed to retry task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusInProgress,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)),
WithContextInfo(ctx),
))
// Dispatch immediately (retry is an explicit action, not during a turn).
t.manager.DispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
assignee := t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
return NewResult(fmt.Sprintf("Task #%d \"%s\" (id: %s) retried and dispatched to %s. The assignee will receive the task with your recent comments.", task.TaskNumber, task.Subject, taskID, assignee))
}