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
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
if err := t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID); err != nil {
|
|
return ErrorResult("failed to claim task: " + err.Error())
|
|
}
|
|
|
|
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusInProgress,
|
|
OwnerAgentKey: ownerKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: ownerKey,
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Task %s claimed successfully. It is now in progress.", taskID))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result {
|
|
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
|
// not complete tasks directly when a reviewer is required.
|
|
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
|
// return ErrorResult("teammate agents cannot complete team tasks directly — results are auto-completed when delegation finishes")
|
|
// }
|
|
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
result, _ := args["result"].(string)
|
|
if result == "" {
|
|
return ErrorResult("result is required for complete action")
|
|
}
|
|
|
|
// Auto-claim if the task is still pending (saves an extra tool call).
|
|
// ClaimTask is atomic — only one agent can succeed, others get an error.
|
|
// Ignore claim error: task may already be in_progress (claimed by us or someone else).
|
|
_ = t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID)
|
|
|
|
if err := t.manager.teamStore.CompleteTask(ctx, taskID, team.ID, result); err != nil {
|
|
return ErrorResult("failed to complete task: " + err.Error())
|
|
}
|
|
|
|
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
|
|
// Fetch task for TaskNumber/Subject needed by notification subscriber.
|
|
completedTask, _ := t.manager.teamStore.GetTask(ctx, taskID)
|
|
var taskNumber int
|
|
var taskSubject string
|
|
if completedTask != nil {
|
|
taskNumber = completedTask.TaskNumber
|
|
taskSubject = completedTask.Subject
|
|
}
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
TaskNumber: taskNumber,
|
|
Subject: taskSubject,
|
|
Status: store.TeamTaskStatusCompleted,
|
|
OwnerAgentKey: ownerKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: ownerKey,
|
|
})
|
|
|
|
// Dependent tasks are dispatched by the consumer after this agent's turn ends
|
|
// (post-turn), not mid-turn. This prevents dependent tasks from completing and
|
|
// announcing to the leader before this agent's own run finishes.
|
|
|
|
return NewResult(fmt.Sprintf("Task %s completed. Dependent tasks will be dispatched after this turn ends.", taskID))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
|
|
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
|
// not cancel tasks directly when a reviewer is required.
|
|
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
|
// return ErrorResult("teammate agents cannot cancel team tasks directly")
|
|
// }
|
|
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
reason, _ := args["text"].(string)
|
|
if reason == "" {
|
|
reason = "Cancelled by agent"
|
|
}
|
|
|
|
// CancelTask: guards against completed tasks, unblocks dependents, transitions blocked→pending.
|
|
if err := t.manager.teamStore.CancelTask(ctx, taskID, team.ID, reason); err != nil {
|
|
return ErrorResult("failed to cancel task: " + err.Error())
|
|
}
|
|
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusCancelled,
|
|
Reason: reason,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
|
})
|
|
|
|
// Dependent tasks are dispatched by the consumer after this agent's turn ends (post-turn).
|
|
|
|
return NewResult(fmt.Sprintf("Task %s cancelled. Dependent tasks will be unblocked after this turn ends.", taskID))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Verify the agent owns this task.
|
|
task, err := t.manager.teamStore.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 submit for review")
|
|
}
|
|
|
|
if err := t.manager.teamStore.ReviewTask(ctx, taskID, team.ID); err != nil {
|
|
return ErrorResult("failed to submit for review: " + err.Error())
|
|
}
|
|
|
|
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusInReview,
|
|
OwnerAgentKey: ownerKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: ownerKey,
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Task %s submitted for review.", taskID))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
|
|
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
|
// not approve tasks directly when a reviewer is required.
|
|
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
|
// return ErrorResult("teammate agents cannot approve team tasks")
|
|
// }
|
|
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Only lead can approve tasks via tool (non-lead agents should not approve).
|
|
// System/dashboard channels bypass this check (human UI approval).
|
|
ch := ToolChannelFromCtx(ctx)
|
|
if ch != ChannelSystem && ch != ChannelDashboard {
|
|
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Fetch task for subject (used in lead message) and team ownership check
|
|
task, err := t.manager.teamStore.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")
|
|
}
|
|
|
|
// Atomic transition: in_review -> completed
|
|
if err := t.manager.teamStore.ApproveTask(ctx, taskID, team.ID, ""); err != nil {
|
|
return ErrorResult("failed to approve task: " + err.Error())
|
|
}
|
|
|
|
// Re-fetch to get the actual post-approval status (pending or blocked)
|
|
approved, _ := t.manager.teamStore.GetTask(ctx, taskID)
|
|
newStatus := store.TeamTaskStatusPending
|
|
if approved != nil {
|
|
newStatus = approved.Status
|
|
}
|
|
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
Subject: task.Subject,
|
|
Status: newStatus,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
|
})
|
|
|
|
// Inject message to lead agent via mailbox
|
|
msg := fmt.Sprintf("Task '%s' (id=%s) has been approved by the user (status: %s).", task.Subject, task.ID, newStatus)
|
|
_ = t.manager.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
|
TeamID: team.ID,
|
|
FromAgentID: team.LeadAgentID,
|
|
ToAgentID: &team.LeadAgentID,
|
|
Content: msg,
|
|
MessageType: store.TeamMessageTypeChat,
|
|
TaskID: &taskID,
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Task %s approved (status: %s).", taskID, newStatus))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
|
|
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
|
// not reject tasks directly when a reviewer is required.
|
|
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
|
// return ErrorResult("teammate agents cannot reject team tasks")
|
|
// }
|
|
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Only lead can reject tasks via tool.
|
|
ch := ToolChannelFromCtx(ctx)
|
|
if ch != ChannelSystem && ch != ChannelDashboard {
|
|
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
}
|
|
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
reason, _ := args["text"].(string)
|
|
if reason == "" {
|
|
reason = "Rejected by user"
|
|
}
|
|
|
|
// Fetch task to get subject for the lead message
|
|
task, err := t.manager.teamStore.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")
|
|
}
|
|
|
|
// Reuse CancelTask (handles unblocking dependents, guards against completed)
|
|
if err := t.manager.teamStore.CancelTask(ctx, taskID, team.ID, reason); err != nil {
|
|
return ErrorResult("failed to reject task: " + err.Error())
|
|
}
|
|
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: taskID.String(),
|
|
Subject: task.Subject,
|
|
Status: "cancelled",
|
|
Reason: reason,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
|
})
|
|
|
|
// Inject message to lead agent via mailbox
|
|
leadMsg := fmt.Sprintf("Task '%s' (id=%s) was rejected by the user. Reason: %s", task.Subject, task.ID, reason)
|
|
_ = t.manager.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
|
TeamID: team.ID,
|
|
FromAgentID: team.LeadAgentID,
|
|
ToAgentID: &team.LeadAgentID,
|
|
Content: leadMsg,
|
|
MessageType: store.TeamMessageTypeChat,
|
|
TaskID: &taskID,
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Task %s rejected. Dependent tasks have been unblocked.", taskID))
|
|
}
|