mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
49441f7305
- Remove handleDelegateAnnounce() dead code (no sender emits delegate:* messages) - Remove delegate tool reference from intent_classify.go - Rename LaneDelegate → LaneTeam with backward-compat env var fallback - Rename ChannelDelegate → ChannelTeammate across all team tool files - Comment out lifecycle guards in team_tasks_lifecycle.go (TODO: reviewer workflow) - Update string literals in cron.go, task_ticker.go - Gate tool_status placeholder_update to non-streaming runs only - Skip FinalizeStream on tool.call to prevent mid-run content loss
347 lines
11 KiB
Go
347 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const listPageSize = 30
|
|
|
|
// blockerSummary is a compact view of a blocker task for blocked_by resolution.
|
|
type blockerSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Subject string `json:"subject"`
|
|
Status string `json:"status"`
|
|
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
|
}
|
|
|
|
// taskListItem is the slim view returned by list/search actions.
|
|
// Excludes UUIDs (owner_agent_id, created_by_agent_id) and task_number — model uses
|
|
// agent keys and identifier instead.
|
|
type taskListItem struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
Subject string `json:"subject"`
|
|
Status string `json:"status"`
|
|
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
|
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
|
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
|
|
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
|
|
ProgressPercent int `json:"progress_percent,omitempty"`
|
|
ProgressStep string `json:"progress_step,omitempty"`
|
|
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// taskDetailItem is the slim view returned by the get action.
|
|
type taskDetailItem struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
Subject string `json:"subject"`
|
|
Description string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
Result *string `json:"result,omitempty"`
|
|
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
|
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
|
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
|
|
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
|
|
ProgressPercent int `json:"progress_percent,omitempty"`
|
|
ProgressStep string `json:"progress_step,omitempty"`
|
|
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
|
|
Priority int `json:"priority"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// slimComment is the slim comment view for get response.
|
|
type slimComment struct {
|
|
AgentKey string `json:"agent_key"`
|
|
Content string `json:"content"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// slimEvent is the slim event view for get response.
|
|
type slimEvent struct {
|
|
EventType string `json:"event_type"`
|
|
ActorID string `json:"actor_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// teamCreateLocks serializes list→create flows per (teamID:chatID) pair.
|
|
var teamCreateLocks sync.Map // key: "teamID:chatID" → *sync.Mutex
|
|
|
|
func getTeamCreateLock(teamID, chatID string) *sync.Mutex {
|
|
key := teamID + ":" + chatID
|
|
v, _ := teamCreateLocks.LoadOrStore(key, &sync.Mutex{})
|
|
return v.(*sync.Mutex)
|
|
}
|
|
|
|
// resolveBlockers batch-loads blocker tasks and returns slim summaries.
|
|
func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UUID) []blockerSummary {
|
|
if len(blockedBy) == 0 {
|
|
return nil
|
|
}
|
|
tasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out := make([]blockerSummary, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
out = append(out, blockerSummary{
|
|
ID: task.ID,
|
|
Subject: task.Subject,
|
|
Status: task.Status,
|
|
OwnerAgentKey: task.OwnerAgentKey,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData) taskListItem {
|
|
return taskListItem{
|
|
ID: task.ID,
|
|
Identifier: task.Identifier,
|
|
Subject: task.Subject,
|
|
Status: task.Status,
|
|
OwnerAgentKey: task.OwnerAgentKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
|
|
CreatedByAgentKey: task.CreatedByAgentKey,
|
|
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
|
|
ProgressPercent: task.ProgressPercent,
|
|
ProgressStep: task.ProgressStep,
|
|
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
|
|
CreatedAt: task.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskData) taskDetailItem {
|
|
return taskDetailItem{
|
|
ID: task.ID,
|
|
Identifier: task.Identifier,
|
|
Subject: task.Subject,
|
|
Description: task.Description,
|
|
Status: task.Status,
|
|
Result: task.Result,
|
|
OwnerAgentKey: task.OwnerAgentKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
|
|
CreatedByAgentKey: task.CreatedByAgentKey,
|
|
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
|
|
ProgressPercent: task.ProgressPercent,
|
|
ProgressStep: task.ProgressStep,
|
|
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
|
|
Priority: task.Priority,
|
|
CreatedAt: task.CreatedAt,
|
|
UpdatedAt: task.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
|
|
team, _, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
statusFilter, _ := args["status"].(string)
|
|
|
|
page := 1
|
|
if p, ok := args["page"].(float64); ok && int(p) > 1 {
|
|
page = int(p)
|
|
}
|
|
offset := (page - 1) * listPageSize
|
|
|
|
// Teammate/system channels see all tasks; end users only see their own.
|
|
filterUserID := ""
|
|
channel := ToolChannelFromCtx(ctx)
|
|
if channel != ChannelTeammate && channel != ChannelSystem {
|
|
filterUserID = store.UserIDFromContext(ctx)
|
|
}
|
|
chatID := ToolChatIDFromCtx(ctx)
|
|
// Shared workspace: show all tasks across chats.
|
|
listChatID := chatID
|
|
if IsSharedWorkspace(team.Settings) {
|
|
listChatID = ""
|
|
}
|
|
|
|
// Acquire team create lock to serialize list→create flows across concurrent goroutines.
|
|
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
|
|
lock := getTeamCreateLock(team.ID.String(), chatID)
|
|
lock.Lock()
|
|
ptd.SetTeamLock(lock)
|
|
ptd.MarkListed()
|
|
}
|
|
|
|
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset)
|
|
if err != nil {
|
|
return ErrorResult("failed to list tasks: " + err.Error())
|
|
}
|
|
|
|
hasMore := len(tasks) > listPageSize
|
|
if hasMore {
|
|
tasks = tasks[:listPageSize]
|
|
}
|
|
|
|
items := make([]taskListItem, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
items = append(items, t.toListItem(ctx, task))
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"tasks": items,
|
|
"count": len(items),
|
|
"page": page,
|
|
}
|
|
if hasMore {
|
|
resp["has_more"] = true
|
|
}
|
|
|
|
out, _ := json.Marshal(resp)
|
|
return SilentResult(string(out))
|
|
}
|
|
|
|
// resolveTaskID extracts and validates the task_id from tool arguments.
|
|
// Falls back to the dispatched task ID from context when task_id is empty or
|
|
// not a valid UUID (agents often pass task_number like "1" instead of the UUID).
|
|
func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error) {
|
|
taskIDStr, _ := args["task_id"].(string)
|
|
|
|
// Try parsing as UUID first.
|
|
if taskIDStr != "" {
|
|
if id, err := uuid.Parse(taskIDStr); err == nil {
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
// Fall back to the dispatched team task ID from context.
|
|
if ctxID := TeamTaskIDFromCtx(ctx); ctxID != "" {
|
|
if id, err := uuid.Parse(ctxID); err == nil {
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
if taskIDStr == "" {
|
|
return uuid.Nil, fmt.Errorf("task_id is required")
|
|
}
|
|
return uuid.Nil, fmt.Errorf("invalid task_id %q — use the UUID from task list, not the task number", taskIDStr)
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result {
|
|
team, _, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return ErrorResult("failed to get task: " + err.Error())
|
|
}
|
|
if task.TeamID != team.ID {
|
|
return ErrorResult("task does not belong to your team")
|
|
}
|
|
|
|
// Truncate result for context protection
|
|
const maxResultRunes = 8000
|
|
if task.Result != nil {
|
|
r := []rune(*task.Result)
|
|
if len(r) > maxResultRunes {
|
|
s := string(r[:maxResultRunes]) + "..."
|
|
task.Result = &s
|
|
}
|
|
}
|
|
|
|
detail := t.toDetailItem(ctx, task)
|
|
|
|
// Load and slim comments/events/attachments
|
|
resp := map[string]any{"task": detail}
|
|
|
|
if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 {
|
|
slim := make([]slimComment, 0, len(comments))
|
|
for _, c := range comments {
|
|
key := ""
|
|
if c.AgentID != nil {
|
|
key = t.manager.agentKeyFromID(ctx, *c.AgentID)
|
|
}
|
|
slim = append(slim, slimComment{
|
|
AgentKey: key,
|
|
Content: c.Content,
|
|
CreatedAt: c.CreatedAt,
|
|
})
|
|
}
|
|
resp["comments"] = slim
|
|
}
|
|
|
|
if events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID); len(events) > 0 {
|
|
slim := make([]slimEvent, 0, len(events))
|
|
for _, e := range events {
|
|
slim = append(slim, slimEvent{
|
|
EventType: e.EventType,
|
|
ActorID: e.ActorID,
|
|
CreatedAt: e.CreatedAt,
|
|
})
|
|
}
|
|
resp["events"] = slim
|
|
}
|
|
|
|
if attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID); len(attachments) > 0 {
|
|
resp["attachments"] = attachments
|
|
}
|
|
|
|
out, _ := json.Marshal(resp)
|
|
return SilentResult(string(out))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
|
|
team, _, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
query, _ := args["query"].(string)
|
|
if query == "" {
|
|
return ErrorResult("query is required for search action")
|
|
}
|
|
|
|
// Teammate/system channels see all tasks; end users only see their own.
|
|
filterUserID := ""
|
|
channel := ToolChannelFromCtx(ctx)
|
|
if channel != ChannelTeammate && channel != ChannelSystem {
|
|
filterUserID = store.UserIDFromContext(ctx)
|
|
}
|
|
|
|
// Acquire team create lock so search also satisfies the list-before-create gate.
|
|
chatID := ToolChatIDFromCtx(ctx)
|
|
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
|
|
lock := getTeamCreateLock(team.ID.String(), chatID)
|
|
lock.Lock()
|
|
ptd.SetTeamLock(lock)
|
|
ptd.MarkListed()
|
|
}
|
|
|
|
tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, listPageSize, filterUserID)
|
|
if err != nil {
|
|
return ErrorResult("failed to search tasks: " + err.Error())
|
|
}
|
|
|
|
items := make([]taskListItem, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
items = append(items, t.toListItem(ctx, task))
|
|
}
|
|
|
|
out, _ := json.Marshal(map[string]any{
|
|
"tasks": items,
|
|
"count": len(items),
|
|
})
|
|
return SilentResult(string(out))
|
|
}
|