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
This commit is contained in:
viettranx committed 2026-03-28 17:25:59 +07:00
1 parent 4b6ed8cfce
commit 78253f1841
28 files changed
+1591 -1392

No files matched your search

+8 -8
View File
@@ -177,18 +177,18 @@ func runGateway() {
label = fmt.Sprintf("%d tasks", len(items))
}
batchMeta := map[string]string{
"origin_channel": meta.OriginChannel,
"origin_peer_kind": meta.OriginPeerKind,
"parent_agent": meta.ParentAgent,
"subagent_label": label,
"origin_trace_id": meta.OriginTraceID,
"origin_root_span_id": meta.OriginRootSpanID,
tools.MetaOriginChannel: meta.OriginChannel,
tools.MetaOriginPeerKind: meta.OriginPeerKind,
tools.MetaParentAgent: meta.ParentAgent,
tools.MetaSubagentLabel: label,
tools.MetaOriginTraceID: meta.OriginTraceID,
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
}
if meta.OriginLocalKey != "" {
batchMeta["origin_local_key"] = meta.OriginLocalKey
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
}
if meta.OriginSessionKey != "" {
batchMeta["origin_session_key"] = meta.OriginSessionKey
batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey
}
// Collect media from all items in the batch.
var batchMedia []bus.MediaFile
+25 -11
View File
@@ -43,9 +43,24 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
return v.(*sync.Mutex)
}
// Construct shared dependencies once — passed by pointer to all handlers.
deps := &ConsumerDeps{
Cfg: cfg,
Agents: agents,
Sched: sched,
ChannelMgr: channelMgr,
MsgBus: msgBus,
TeamStore: teamStore,
AgentStore: agentStore,
SessStore: sessStore,
PostTurn: postTurn,
QuotaChecker: quotaChecker,
ContactCollector: contactCollector,
GetAnnounceMu: getAnnounceMu,
}
// Track running teammate tasks so they can be cancelled when the task is
// cancelled/failed externally (e.g. lead cancels via team_tasks tool).
var taskRunSessions sync.Map // taskID (string) → sessionKey (string)
msgBus.Subscribe("consumer.team-task-cancel", func(event bus.Event) {
if event.Name != protocol.EventTeamTaskCancelled && event.Name != protocol.EventTeamTaskFailed {
return
@@ -54,12 +69,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
if !ok {
return
}
if sessKey, ok := taskRunSessions.Load(payload.TaskID); ok {
if sessKey, ok := deps.TaskRunSessions.Load(payload.TaskID); ok {
if cancelled := sched.CancelSession(sessKey.(string)); cancelled {
slog.Info("team task cancelled: stopped running agent",
"task_id", payload.TaskID, "session", sessKey)
}
taskRunSessions.Delete(payload.TaskID)
deps.TaskRunSessions.Delete(payload.TaskID)
}
})
@@ -72,7 +87,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
debouncer := bus.NewInboundDebouncer(
time.Duration(debounceMs)*time.Millisecond,
func(msg bus.InboundMessage) {
processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus)
processNormalMessage(ctx, msg, deps)
},
)
defer debouncer.Stop()
@@ -81,9 +96,8 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
// Track background goroutines (subagent announces, teammate messages)
// so shutdown can wait for in-flight work to complete.
var bgWg sync.WaitGroup
defer func() {
bgWg.Wait()
deps.BgWg.Wait()
slog.Info("inbound consumer: all background goroutines drained")
}()
@@ -103,22 +117,22 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
}
}
if handleSubagentAnnounce(ctx, msg, cfg, sched, channelMgr, msgBus, getAnnounceMu, &bgWg) {
if handleSubagentAnnounce(ctx, msg, deps) {
continue
}
if handleTeammateMessage(ctx, msg, cfg, sched, channelMgr, teamStore, agentStore, msgBus, postTurn, &taskRunSessions, &bgWg) {
if handleTeammateMessage(ctx, msg, deps) {
continue
}
if handleResetCommand(msg, cfg, sessStore) {
if handleResetCommand(msg, deps) {
continue
}
if handleStopCommand(msg, cfg, sched, sessStore, msgBus) {
if handleStopCommand(msg, deps) {
continue
}
// Blocker escalation messages bypass debounce — deliver immediately to leader.
if msg.SenderID == "system:escalation" {
go processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus)
go processNormalMessage(ctx, msg, deps)
continue
}
+32
View File
@@ -0,0 +1,32 @@
package cmd
import (
"sync"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// ConsumerDeps bundles shared dependencies for consumer message handlers.
// Replaces 11+ positional params with a single injectable struct.
type ConsumerDeps struct {
Cfg *config.Config
Agents *agent.Router
Sched *scheduler.Scheduler
ChannelMgr *channels.Manager
MsgBus *bus.MessageBus
TeamStore store.TeamStore
AgentStore store.AgentStore
SessStore store.SessionStore
PostTurn tools.PostTurnProcessor
QuotaChecker *channels.QuotaChecker
ContactCollector *store.ContactCollector
TaskRunSessions sync.Map
BgWg sync.WaitGroup
GetAnnounceMu func(string) *sync.Mutex
}
+95 -254
View File
@@ -7,22 +7,17 @@ import (
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/safego"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// handleSubagentAnnounce processes subagent announce messages: bypass debounce,
@@ -31,12 +26,7 @@ import (
func handleSubagentAnnounce(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
msgBus *bus.MessageBus,
getAnnounceMu func(string) *sync.Mutex,
bgWg *sync.WaitGroup,
deps *ConsumerDeps,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "subagent:")) {
return false
@@ -49,11 +39,11 @@ func handleSubagentAnnounce(
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChannelType := resolveChannelType(channelMgr, origChannel)
parentAgent := msg.Metadata["parent_agent"]
origChannel := msg.Metadata[tools.MetaOriginChannel]
origPeerKind := msg.Metadata[tools.MetaOriginPeerKind]
origLocalKey := msg.Metadata[tools.MetaOriginLocalKey]
origChannelType := resolveChannelType(deps.ChannelMgr, origChannel)
parentAgent := msg.Metadata[tools.MetaParentAgent]
if parentAgent == "" {
parentAgent = "default"
}
@@ -67,7 +57,7 @@ func handleSubagentAnnounce(
}
// Use exact origin session key if available (WS uses non-standard format).
sessionKey := msg.Metadata["origin_session_key"]
sessionKey := msg.Metadata[tools.MetaOriginSessionKey]
if sessionKey == "" {
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID)
@@ -76,16 +66,16 @@ func handleSubagentAnnounce(
slog.Info("subagent announce → scheduler (subagent lane)",
"subagent", msg.SenderID,
"label", msg.Metadata["subagent_label"],
"label", msg.Metadata[tools.MetaSubagentLabel],
"session", sessionKey,
)
// Extract parent trace context for announce linking
var parentTraceID, parentRootSpanID uuid.UUID
if tid := msg.Metadata["origin_trace_id"]; tid != "" {
if tid := msg.Metadata[tools.MetaOriginTraceID]; tid != "" {
parentTraceID, _ = uuid.Parse(tid)
}
if sid := msg.Metadata["origin_root_span_id"]; sid != "" {
if sid := msg.Metadata[tools.MetaOriginRootSpanID]; sid != "" {
parentRootSpanID, _ = uuid.Parse(sid)
}
@@ -105,7 +95,7 @@ func handleSubagentAnnounce(
fwdMedia := msg.Media
contentSuffix := ""
if origChannel == "ws" && len(msg.Media) > 0 {
contentSuffix = mediaToMarkdownFromPaths(msg.Media, cfg)
contentSuffix = mediaToMarkdownFromPaths(msg.Media, deps.Cfg)
fwdMedia = nil // WS: images delivered via ContentSuffix, not ForwardMedia
}
@@ -130,15 +120,15 @@ func handleSubagentAnnounce(
// Handle announce asynchronously with per-session serialization.
// The mutex ensures concurrent announces for the same session wait for
// each other, so each reads up-to-date session history.
bgWg.Add(1)
deps.BgWg.Add(1)
go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest) {
defer bgWg.Done()
defer deps.BgWg.Done()
defer safego.Recover(nil, "component", "subagent_announce", "session", sessionKey)
mu := getAnnounceMu(sessionKey)
mu := deps.GetAnnounceMu(sessionKey)
mu.Lock()
defer mu.Unlock()
outCh := sched.Schedule(ctx, scheduler.LaneSubagent, req)
outCh := deps.Sched.Schedule(ctx, scheduler.LaneSubagent, req)
outcome := <-outCh
if outcome.Err != nil {
if errors.Is(outcome.Err, context.Canceled) {
@@ -146,7 +136,7 @@ func handleSubagentAnnounce(
return
}
slog.Error("subagent announce: agent run failed", "error", outcome.Err)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: formatAgentError(outcome.Err),
@@ -178,8 +168,8 @@ func handleSubagentAnnounce(
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq)
deps.MsgBus.PublishOutbound(outMsg)
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata[tools.MetaSubagentLabel], outMeta, announceReq)
return true
}
@@ -190,15 +180,7 @@ func handleSubagentAnnounce(
func handleTeammateMessage(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
teamStore store.TeamStore,
agentStore store.AgentStore,
msgBus *bus.MessageBus,
postTurn tools.PostTurnProcessor,
taskRunSessions *sync.Map,
bgWg *sync.WaitGroup,
deps *ConsumerDeps,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "teammate:")) {
return false
@@ -211,17 +193,17 @@ func handleTeammateMessage(
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChatID := msg.Metadata["origin_chat_id"] // original chat (e.g. Telegram chat ID)
origChannel := msg.Metadata[tools.MetaOriginChannel]
origPeerKind := msg.Metadata[tools.MetaOriginPeerKind]
origLocalKey := msg.Metadata[tools.MetaOriginLocalKey]
origChatID := msg.Metadata[tools.MetaOriginChatID] // original chat (e.g. Telegram chat ID)
if origChatID == "" {
origChatID = msg.ChatID // fallback to inbound ChatID (team UUID for old dispatches)
}
origChannelType := resolveChannelType(channelMgr, origChannel)
origChannelType := resolveChannelType(deps.ChannelMgr, origChannel)
targetAgent := msg.AgentID // dispatch sets AgentID to the target agent key
if targetAgent == "" {
targetAgent = cfg.ResolveDefaultAgentID()
targetAgent = deps.Cfg.ResolveDefaultAgentID()
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
@@ -241,13 +223,13 @@ func handleTeammateMessage(
// Use isolated team session key so member execution doesn't share
// the user's direct chat session with this agent.
// Scoped per agent + team + chatID, matching workspace isolation.
sessionKey := sessions.BuildTeamSessionKey(targetAgent, msg.Metadata["team_id"], origChatID)
sessionKey := sessions.BuildTeamSessionKey(targetAgent, msg.Metadata[tools.MetaTeamID], origChatID)
slog.Info("teammate message → scheduler (team lane)",
"from", msg.SenderID,
"to", targetAgent,
"session", sessionKey,
"team_task_id", msg.Metadata["team_task_id"],
"team_task_id", msg.Metadata[tools.MetaTeamTaskID],
)
announceUserID := msg.UserID
@@ -259,14 +241,14 @@ func handleTeammateMessage(
// Link member agent trace back to lead's trace for unified tracing.
var linkedTraceID uuid.UUID
if tid := msg.Metadata["origin_trace_id"]; tid != "" {
if tid := msg.Metadata[tools.MetaOriginTraceID]; tid != "" {
linkedTraceID, _ = uuid.Parse(tid)
}
// Track task → session so the subscriber can cancel on task cancellation.
taskIDStr := msg.Metadata["team_task_id"]
taskIDStr := msg.Metadata[tools.MetaTeamTaskID]
if taskIDStr != "" {
taskRunSessions.Store(taskIDStr, sessionKey)
deps.TaskRunSessions.Store(taskIDStr, sessionKey)
}
// Inject action flags into context so team_tasks tool calls record what happened.
@@ -274,7 +256,7 @@ func handleTeammateMessage(
taskActionFlags := &tools.TaskActionFlags{}
schedCtx := tools.WithTaskActionFlags(ctx, taskActionFlags)
outCh := sched.Schedule(schedCtx, scheduler.LaneTeam, agent.RunRequest{
outCh := deps.Sched.Schedule(schedCtx, scheduler.LaneTeam, agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Channel: origChannel,
@@ -283,194 +265,57 @@ func handleTeammateMessage(
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata["from_agent"], msg.Metadata["to_agent"]),
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata[tools.MetaFromAgent], msg.Metadata[tools.MetaToAgent]),
Stream: false,
TeamTaskID: msg.Metadata["team_task_id"],
TeamWorkspace: msg.Metadata["team_workspace"],
LeaderAgentID: msg.Metadata["leader_agent_id"],
TeamTaskID: msg.Metadata[tools.MetaTeamTaskID],
TeamWorkspace: msg.Metadata[tools.MetaTeamWorkspace],
LeaderAgentID: msg.Metadata[tools.MetaLeaderAgentID],
WorkspaceChatID: origChatID,
TeamID: msg.Metadata["team_id"],
TeamID: msg.Metadata[tools.MetaTeamID],
LinkedTraceID: linkedTraceID,
})
bgWg.Add(1)
go func(origCh, origChatID, senderID, taskID string, meta, inMeta map[string]string) {
defer bgWg.Done()
deps.BgWg.Add(1)
go func(origCh, origChatID, senderID, taskID string, outMeta, inMeta map[string]string) {
defer deps.BgWg.Done()
defer safego.Recover(nil, "component", "teammate_message", "task_id", taskID)
// Lock renewal heartbeat: extend task lock every 5 min to prevent
// the ticker from recovering long-running tasks as stale.
var lockStop chan struct{}
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" && teamStore != nil {
var lockStop func()
if taskIDStr := inMeta[tools.MetaTeamTaskID]; taskIDStr != "" && deps.TeamStore != nil {
teamTaskID, _ := uuid.Parse(taskIDStr)
teamID, _ := uuid.Parse(inMeta["team_id"])
if teamTaskID != uuid.Nil {
lockStop = make(chan struct{})
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := teamStore.RenewTaskLock(ctx, teamTaskID, teamID); err != nil {
slog.Warn("teammate lock renewal failed", "task_id", teamTaskID, "error", err)
return
}
slog.Debug("teammate lock renewed", "task_id", teamTaskID)
case <-lockStop:
return
case <-ctx.Done():
return
}
}
}()
}
teamID, _ := uuid.Parse(inMeta[tools.MetaTeamID])
lockStop = startTaskLockRenewal(ctx, deps.TeamStore, teamTaskID, teamID)
}
outcome := <-outCh
// Clean up task → session tracking now that the agent has finished.
if taskID != "" {
taskRunSessions.Delete(taskID)
deps.TaskRunSessions.Delete(taskID)
}
// Stop lock renewal now that the agent has finished.
if lockStop != nil {
close(lockStop)
lockStop()
}
// Auto-complete/fail the associated team task (v2 only).
// Cache team lookup — reused later for announce routing.
var cachedTeam *store.TeamData
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" {
if taskIDStr := inMeta[tools.MetaTeamTaskID]; taskIDStr != "" {
teamTaskID, _ := uuid.Parse(taskIDStr)
teamID, _ := uuid.Parse(inMeta["team_id"])
if teamTaskID != uuid.Nil && teamStore != nil {
cachedTeam, _ = teamStore.GetTeam(ctx, teamID)
if cachedTeam != nil {
// Check current task status — agent may have already updated it via tool.
currentTask, taskErr := teamStore.GetTask(ctx, teamTaskID)
alreadyTerminal := taskErr == nil && currentTask != nil &&
(currentTask.Status == store.TeamTaskStatusCompleted ||
currentTask.Status == store.TeamTaskStatusFailed ||
currentTask.Status == store.TeamTaskStatusCancelled)
if !alreadyTerminal {
toAgent := inMeta["to_agent"]
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
// Enrich event payload with task details for notifications.
taskSubject := ""
taskNumber := 0
taskChannel := inMeta["origin_channel"]
taskChatID := inMeta["origin_chat_id"]
if currentTask != nil {
taskSubject = currentTask.Subject
taskNumber = currentTask.TaskNumber
if currentTask.Channel != "" {
taskChannel = currentTask.Channel
}
if currentTask.ChatID != "" {
taskChatID = currentTask.ChatID
}
}
// Smart post-turn decision based on action flags.
// Only error, completed/escalated, and reviewed block auto-complete.
switch {
case outcome.Err != nil:
// Agent errored → auto-fail.
if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil {
slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err)
} else {
bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusFailed,
Reason: outcome.Err.Error(),
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
})
}
case taskActionFlags.Completed || taskActionFlags.Escalated:
// Tool already completed/failed the task — skip auto-complete.
slog.Info("post-turn: tool handled task", "task_id", teamTaskID,
"completed", taskActionFlags.Completed, "escalated", taskActionFlags.Escalated)
case taskActionFlags.Reviewed:
// Task submitted for review — skip auto-complete, renew lock.
_ = teamStore.RenewTaskLock(ctx, teamTaskID, teamID)
slog.Info("post-turn: task submitted for review", "task_id", teamTaskID)
case outcome.Result != nil && outcome.Result.LoopKilled:
// Loop detector killed the run → auto-fail instead of auto-complete.
// The agent was terminated (not stuck in_progress) — mark as failed
// so the leader sees clear failure signal and can retry directly.
failMsg := outcome.Result.Content
if failMsg == "" {
failMsg = "Agent run terminated by loop detector"
}
if err := teamStore.FailTask(ctx, teamTaskID, teamID, failMsg); err != nil {
slog.Warn("auto-fail: FailTask error (loop kill)", "task_id", teamTaskID, "error", err)
} else {
bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusFailed,
Reason: "loop_detector_kill",
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "system",
ActorID: "loop_detector",
})
}
slog.Warn("post-turn: loop detector killed member run",
"task_id", teamTaskID, "agent", toAgent)
default:
// Agent turn ended without terminal action — auto-complete.
// Covers: Progressed, Commented, Claimed, or no flags at all.
if outcome.Result != nil {
result := outcome.Result.Content
if len(outcome.Result.Deliverables) > 0 {
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
}
if len(result) > 100_000 {
result = result[:100_000] + "\n[truncated]"
}
if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil {
slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err)
} else {
bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: toAgent,
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
})
}
}
}
}
// Always dispatch unblocked tasks after member turn ends,
// regardless of whether the task was already completed by the tool.
// This ensures dependent tasks start only after the member's run finishes.
if postTurn != nil {
postTurn.DispatchUnblockedTasks(ctx, teamID)
}
teamID, _ := uuid.Parse(inMeta[tools.MetaTeamID])
if teamTaskID != uuid.Nil {
meta := teammateTaskMeta{
TaskID: teamTaskID,
TeamID: teamID,
ToAgent: inMeta[tools.MetaToAgent],
Channel: inMeta[tools.MetaOriginChannel],
ChatID: inMeta[tools.MetaOriginChatID],
}
cachedTeam = resolveTeamTaskOutcome(ctx, deps, outcome, taskActionFlags, meta)
}
}
@@ -496,9 +341,9 @@ func handleTeammateMessage(
}
// Append member comments & attachments so leader sees them in the announce.
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" && teamStore != nil {
if taskIDStr := inMeta[tools.MetaTeamTaskID]; taskIDStr != "" && deps.TeamStore != nil {
if taskUUID, err := uuid.Parse(taskIDStr); err == nil {
if comments, err := teamStore.ListRecentTaskComments(ctx, taskUUID, 5); err == nil && len(comments) > 0 {
if comments, err := deps.TeamStore.ListRecentTaskComments(ctx, taskUUID, 5); err == nil && len(comments) > 0 {
var parts []string
for _, c := range comments {
author := c.AgentKey
@@ -513,7 +358,7 @@ func handleTeammateMessage(
}
announceContent += "\n\n[Member notes]\n" + strings.Join(parts, "\n")
}
if attachments, err := teamStore.ListTaskAttachments(ctx, taskUUID); err == nil && len(attachments) > 0 {
if attachments, err := deps.TeamStore.ListTaskAttachments(ctx, taskUUID); err == nil && len(attachments) > 0 {
announceContent += "\n\n[Attached files in team workspace]"
for _, a := range attachments {
announceContent += "\n- " + filepath.Base(a.Path)
@@ -532,32 +377,32 @@ func handleTeammateMessage(
// Resolve lead agent.
leadAgent := ""
if cachedTeam != nil {
if leadAg, err := agentStore.GetByID(ctx, cachedTeam.LeadAgentID); err == nil {
if leadAg, err := deps.AgentStore.GetByID(ctx, cachedTeam.LeadAgentID); err == nil {
leadAgent = leadAg.AgentKey
}
} else if teamIDStr := inMeta["team_id"]; teamIDStr != "" {
} else if teamIDStr := inMeta[tools.MetaTeamID]; teamIDStr != "" {
if teamUUID, err := uuid.Parse(teamIDStr); err == nil {
if team, err := teamStore.GetTeam(ctx, teamUUID); err == nil {
if leadAg, err := agentStore.GetByID(ctx, team.LeadAgentID); err == nil {
if team, err := deps.TeamStore.GetTeam(ctx, teamUUID); err == nil {
if leadAg, err := deps.AgentStore.GetByID(ctx, team.LeadAgentID); err == nil {
leadAgent = leadAg.AgentKey
}
}
}
}
if leadAgent == "" {
leadAgent = inMeta["from_agent"]
leadAgent = inMeta[tools.MetaFromAgent]
}
if leadAgent == "" {
leadAgent = cfg.ResolveDefaultAgentID()
leadAgent = deps.Cfg.ResolveDefaultAgentID()
}
origPeerKind := inMeta["origin_peer_kind"]
origPeerKind := inMeta[tools.MetaOriginPeerKind]
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
}
origLocalKey := inMeta["origin_local_key"]
origLocalKey := inMeta[tools.MetaOriginLocalKey]
// Use exact origin session key if available (WS uses non-standard format).
leadSessionKey := inMeta["origin_session_key"]
leadSessionKey := inMeta[tools.MetaOriginSessionKey]
if leadSessionKey == "" {
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
leadSessionKey = sessions.BuildScopedSessionKey(leadAgent, origCh, sessions.PeerKind(origPeerKind), origChatID)
@@ -566,10 +411,10 @@ func handleTeammateMessage(
// Extract trace context for announce linking.
var parentTraceID, parentRootSpanID uuid.UUID
if tid := inMeta["origin_trace_id"]; tid != "" {
if tid := inMeta[tools.MetaOriginTraceID]; tid != "" {
parentTraceID, _ = uuid.Parse(tid)
}
if sid := inMeta["origin_root_span_id"]; sid != "" {
if sid := inMeta[tools.MetaOriginRootSpanID]; sid != "" {
parentRootSpanID, _ = uuid.Parse(sid)
}
@@ -580,8 +425,8 @@ func handleTeammateMessage(
// Enqueue result. If we become the processor, run the announce loop.
entry := announceEntry{
MemberAgent: inMeta["to_agent"],
MemberDisplayName: inMeta["to_agent_display"],
MemberAgent: inMeta[tools.MetaToAgent],
MemberDisplayName: inMeta[tools.MetaToAgentDisplay],
Content: announceContent,
Media: announceMedia,
}
@@ -599,15 +444,15 @@ func handleTeammateMessage(
OrigChatID: origChatID,
OrigPeerKind: origPeerKind,
OrigLocalKey: origLocalKey,
OriginUserID: inMeta["origin_user_id"],
TeamID: inMeta["team_id"],
TeamWorkspace: inMeta["team_workspace"],
OriginTraceID: inMeta["origin_trace_id"],
OriginUserID: inMeta[tools.MetaOriginUserID],
TeamID: inMeta[tools.MetaTeamID],
TeamWorkspace: inMeta[tools.MetaTeamWorkspace],
OriginTraceID: inMeta[tools.MetaOriginTraceID],
ParentTraceID: parentTraceID,
ParentRootSpanID: parentRootSpanID,
OutMeta: meta,
OutMeta: outMeta,
}
processAnnounceLoop(ctx, q, routing, sched, msgBus, teamStore, postTurn, cfg)
processAnnounceLoop(ctx, q, routing, deps.Sched, deps.MsgBus, deps.TeamStore, deps.PostTurn, deps.Cfg)
}(origChannel, origChatID, msg.SenderID, taskIDStr, outMeta, msg.Metadata)
return true
@@ -617,32 +462,31 @@ func handleTeammateMessage(
// Returns true if the message was handled (caller should continue).
func handleResetCommand(
msg bus.InboundMessage,
cfg *config.Config,
sessStore store.SessionStore,
deps *ConsumerDeps,
) bool {
if msg.Metadata["command"] != "reset" {
if msg.Metadata[tools.MetaCommand] != "reset" {
return false
}
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect)
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID)
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
if msg.Metadata[tools.MetaIsForum] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
fmt.Sscanf(msg.Metadata[tools.MetaMessageThreadID], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
ctx := store.WithTenantID(context.Background(), msg.TenantID)
sessStore.Reset(ctx, sessionKey)
sessStore.Save(ctx, sessionKey)
deps.SessStore.Reset(ctx, sessionKey)
deps.SessStore.Save(ctx, sessionKey)
providers.ResetCLISession("", sessionKey)
slog.Info("inbound: /reset command", "session", sessionKey)
@@ -653,28 +497,25 @@ func handleResetCommand(
// Returns true if the message was handled (caller should continue).
func handleStopCommand(
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
sessStore store.SessionStore,
msgBus *bus.MessageBus,
deps *ConsumerDeps,
) bool {
cmd := msg.Metadata["command"]
cmd := msg.Metadata[tools.MetaCommand]
if cmd != "stop" && cmd != "stopall" {
return false
}
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect)
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID)
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
if msg.Metadata[tools.MetaIsForum] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
fmt.Sscanf(msg.Metadata[tools.MetaMessageThreadID], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
@@ -689,14 +530,14 @@ func handleStopCommand(
// sessStore is referenced in the original code but not used in this branch beyond
// session key construction; kept as parameter for API consistency.
_ = sessStore
_ = deps.SessStore
var cancelled bool
if cmd == "stopall" {
cancelled = sched.CancelSession(sessionKey)
cancelled = deps.Sched.CancelSession(sessionKey)
slog.Info("inbound: /stopall command", "session", sessionKey, "cancelled", cancelled)
} else {
cancelled = sched.CancelOneSession(sessionKey)
cancelled = deps.Sched.CancelOneSession(sessionKey)
slog.Info("inbound: /stop command", "session", sessionKey, "cancelled", cancelled)
}
@@ -715,7 +556,7 @@ func handleStopCommand(
feedback = "No active task to stop."
}
}
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: feedback,
@@ -746,7 +587,7 @@ func buildTaskBoardSnapshot(ctx context.Context, teamStore store.TeamStore, team
var active, completed int
var activeLines []string
for _, t := range allTasks {
tid, _ := t.Metadata["origin_trace_id"].(string)
tid, _ := t.Metadata[tools.TaskMetaOriginTrace].(string)
if tid != originTraceID {
continue
}
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// resolveAgentRoute determines which agent should handle a message
@@ -98,9 +99,9 @@ func buildAnnounceOutMeta(localKey string) map[string]string {
}
meta := map[string]string{"local_key": localKey}
if idx := strings.Index(localKey, ":topic:"); idx > 0 {
meta["message_thread_id"] = localKey[idx+7:]
meta[tools.MetaMessageThreadID] = localKey[idx+7:]
} else if idx := strings.Index(localKey, ":thread:"); idx > 0 {
meta["message_thread_id"] = localKey[idx+8:]
meta[tools.MetaMessageThreadID] = localKey[idx+8:]
}
return meta
}
+47 -59
View File
@@ -11,9 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram/voiceguard"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
@@ -26,17 +24,7 @@ import (
func processNormalMessage(
ctx context.Context,
msg bus.InboundMessage,
agents *agent.Router,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
teamStore store.TeamStore,
quotaChecker *channels.QuotaChecker,
sessStore store.SessionStore,
agentStore store.AgentStore,
contactCollector *store.ContactCollector,
postTurn tools.PostTurnProcessor,
msgBus *bus.MessageBus,
deps *ConsumerDeps,
) {
// Inject tenant from channel instance into context so all store operations
// (agent lookup, session creation, etc.) are tenant-scoped.
@@ -49,10 +37,10 @@ func processNormalMessage(
// Determine target agent via bindings or explicit AgentID
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
agentLoop, err := agents.Get(ctx, agentID)
agentLoop, err := deps.Agents.Get(ctx, agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
return
@@ -75,9 +63,9 @@ func processNormalMessage(
// Forum topic: override session key to isolate per-topic history.
// TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
if msg.Metadata[tools.MetaIsForum] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
fmt.Sscanf(msg.Metadata[tools.MetaMessageThreadID], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
@@ -112,32 +100,32 @@ func processNormalMessage(
// Persist friendly names from channel metadata into session + user profile.
sessionMeta := extractSessionMetadata(msg, peerKind)
if len(sessionMeta) > 0 {
sessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta)
if agentStore != nil {
deps.SessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta)
if deps.AgentStore != nil {
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
_ = deps.AgentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
}
}
}
// Auto-collect channel contacts for the contact selector.
if contactCollector != nil && msg.SenderID != "" {
if deps.ContactCollector != nil && msg.SenderID != "" {
senderNumericID := msg.SenderID
if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 {
senderNumericID = senderNumericID[:idx]
}
channelType := channelMgr.ChannelTypeForName(msg.Channel)
channelType := deps.ChannelMgr.ChannelTypeForName(msg.Channel)
if channelType == "" {
channelType = msg.Channel // fallback to instance name
}
displayName := sessionMeta["display_name"]
username := sessionMeta["username"]
contactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind)
deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind)
}
// --- Quota check ---
if quotaChecker != nil {
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
if deps.QuotaChecker != nil {
qResult := deps.QuotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
if !qResult.Allowed {
slog.Warn("security.quota_exceeded",
"user_id", userID,
@@ -146,7 +134,7 @@ func processNormalMessage(
"used", qResult.Used,
"limit", qResult.Limit,
)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: formatQuotaExceeded(qResult),
@@ -154,14 +142,14 @@ func processNormalMessage(
})
return
}
quotaChecker.Increment(userID)
deps.QuotaChecker.Increment(userID)
}
// Auto-clear followup reminders when user sends a message on a real channel.
// Fire-and-forget: don't block message processing.
if teamStore != nil && msg.Channel != tools.ChannelSystem && msg.Channel != tools.ChannelTeammate && msg.Channel != tools.ChannelDashboard {
if deps.TeamStore != nil && msg.Channel != tools.ChannelSystem && msg.Channel != tools.ChannelTeammate && msg.Channel != tools.ChannelDashboard {
go func(ch, cid string) {
if n, err := teamStore.ClearFollowupByScope(ctx, ch, cid); err != nil {
if n, err := deps.TeamStore.ClearFollowupByScope(ctx, ch, cid); err != nil {
slog.Warn("auto-clear followup failed", "channel", ch, "chat_id", cid, "error", err)
} else if n > 0 {
slog.Info("auto-clear followup: cleared", "channel", ch, "chat_id", cid, "count", n)
@@ -181,7 +169,7 @@ func processNormalMessage(
// Enable streaming when the channel supports it (so agent emits chunk events).
// The channel decides per chat type via separate dm_stream / group_stream flags.
isGroup := peerKind == string(sessions.PeerGroup)
enableStream := channelMgr != nil && channelMgr.IsStreamingChannel(msg.Channel, isGroup)
enableStream := deps.ChannelMgr != nil && deps.ChannelMgr.IsStreamingChannel(msg.Channel, isGroup)
// Group chats allow concurrent runs (multiple users can chat simultaneously).
maxConcurrent := 1
@@ -199,7 +187,7 @@ func processNormalMessage(
outMeta["reply_to_message_id"] = mid
}
}
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key", "group_id"} {
for _, k := range []string{tools.MetaMessageThreadID, "local_key", "placeholder_key", "group_id"} {
if v := msg.Metadata[k]; v != "" {
outMeta[k] = v
}
@@ -213,10 +201,10 @@ func processNormalMessage(
if lk := msg.Metadata["local_key"]; lk != "" {
chatIDForRun = lk
}
blockReply := channelMgr != nil && channelMgr.ResolveBlockReply(msg.Channel, cfg.Gateway.BlockReply)
toolStatus := cfg.Gateway.ToolStatus == nil || *cfg.Gateway.ToolStatus // default true
if channelMgr != nil {
channelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, enableStream, blockReply, toolStatus)
blockReply := deps.ChannelMgr != nil && deps.ChannelMgr.ResolveBlockReply(msg.Channel, deps.Cfg.Gateway.BlockReply)
toolStatus := deps.Cfg.Gateway.ToolStatus == nil || *deps.Cfg.Gateway.ToolStatus // default true
if deps.ChannelMgr != nil {
deps.ChannelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, enableStream, blockReply, toolStatus)
}
// Group-aware system prompt: help the LLM adapt tone and behavior for group chats.
@@ -255,7 +243,7 @@ func processNormalMessage(
// Intent classify fast-path: when agent is busy on DM, classify user intent
// to detect status queries, cancel requests, or steer/new_task for mid-run injection.
// Only for DM (maxConcurrent=1) where messages queue behind the active run.
if maxConcurrent == 1 && agents.IsSessionBusy(sessionKey) {
if maxConcurrent == 1 && deps.Agents.IsSessionBusy(sessionKey) {
if loop, ok := agentLoop.(*agent.Loop); ok && loop.Provider() != nil {
locale := msg.Metadata["locale"]
if locale == "" {
@@ -264,9 +252,9 @@ func processNormalMessage(
intent := agent.ClassifyIntent(ctx, loop.Provider(), loop.Model(), msg.Content)
switch intent {
case agent.IntentStatusQuery:
status := agents.GetActivity(sessionKey)
status := deps.Agents.GetActivity(sessionKey)
reply := agent.FormatStatusReply(status, locale)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: reply,
@@ -274,11 +262,11 @@ func processNormalMessage(
})
return
case agent.IntentCancel:
aborted := agents.AbortRunsForSession(sessionKey)
aborted := deps.Agents.AbortRunsForSession(sessionKey)
if len(aborted) > 0 {
slog.Info("inbound: cancelled runs via intent classify",
"session", sessionKey, "aborted", aborted)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: i18n.T(locale, i18n.MsgCancelledReply),
@@ -288,14 +276,14 @@ func processNormalMessage(
return
case agent.IntentSteer:
// Steer: inject into running loop to redirect/add to current task.
injected := agents.InjectMessage(sessionKey, agent.InjectedMessage{
injected := deps.Agents.InjectMessage(sessionKey, agent.InjectedMessage{
Content: msg.Content,
UserID: userID,
})
if injected {
slog.Info("inbound: injected steer message",
"session", sessionKey)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: i18n.T(locale, i18n.MsgInjectedAck),
@@ -329,13 +317,13 @@ func processNormalMessage(
}
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
outCh := sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Media: reqMedia,
ForwardMedia: fwdMedia,
Channel: msg.Channel,
ChannelType: resolveChannelType(channelMgr, msg.Channel),
ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel),
ChatTitle: msg.Metadata["chat_title"],
ChatID: msg.ChatID,
PeerKind: peerKind,
@@ -360,17 +348,17 @@ func processNormalMessage(
ptd.ReleaseTeamLock()
// Post-turn: dispatch pending team tasks created during this turn.
if postTurn != nil {
if deps.PostTurn != nil {
for teamID, taskIDs := range ptd.Drain() {
if err := postTurn.ProcessPendingTasks(ctx, teamID, taskIDs); err != nil {
if err := deps.PostTurn.ProcessPendingTasks(ctx, teamID, taskIDs); err != nil {
slog.Warn("post_turn: failed", "team_id", teamID, "error", err)
}
}
}
// Clean up run tracking (in case HandleAgentEvent didn't fire for terminal events)
if channelMgr != nil {
channelMgr.UnregisterRun(rID)
if deps.ChannelMgr != nil {
deps.ChannelMgr.UnregisterRun(rID)
}
if outcome.Err != nil {
@@ -378,7 +366,7 @@ func processNormalMessage(
// publish empty outbound to clean up thinking/typing indicators.
if errors.Is(outcome.Err, context.Canceled) {
slog.Info("inbound: run cancelled", "channel", channel, "session", session)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
@@ -387,7 +375,7 @@ func processNormalMessage(
return
}
slog.Error("inbound: agent run failed", "error", outcome.Err, "channel", channel)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: formatAgentError(outcome.Err),
@@ -404,7 +392,7 @@ func processNormalMessage(
"chat_id", chatID,
"session", session,
)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
@@ -419,7 +407,7 @@ func processNormalMessage(
if blockReplyEnabled && outcome.Result.BlockReplies > 0 && outcome.Result.Content == outcome.Result.LastBlockReply && len(outcome.Result.Media) == 0 {
slog.Debug("inbound: dedup final message (matches last block reply)",
"channel", channel, "run_id", rID)
msgBus.PublishOutbound(bus.OutboundMessage{
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
@@ -430,11 +418,11 @@ func processNormalMessage(
// Sanitize voice agent replies: replace technical errors with user-friendly fallback.
replyContent := voiceguard.SanitizeReply(
cfg.Channels.Telegram.VoiceAgentID, agentKey,
deps.Cfg.Channels.Telegram.VoiceAgentID, agentKey,
channel, peerKind, inboundContent, outcome.Result.Content,
cfg.Channels.Telegram.AudioGuardFallbackTranscript,
cfg.Channels.Telegram.AudioGuardFallbackNoTranscript,
cfg.Channels.Telegram.AudioGuardErrorMarkers,
deps.Cfg.Channels.Telegram.AudioGuardFallbackTranscript,
deps.Cfg.Channels.Telegram.AudioGuardFallbackNoTranscript,
deps.Cfg.Channels.Telegram.AudioGuardErrorMarkers,
)
// Publish response back to the channel
@@ -447,11 +435,11 @@ func processNormalMessage(
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
deps.MsgBus.PublishOutbound(outMsg)
// Auto-set followup when lead agent replies on a real channel with in_progress tasks.
if teamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, replyContent)
if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent)
}
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd)
}
+207
View File
@@ -0,0 +1,207 @@
package cmd
import (
"context"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// startTaskLockRenewal extends the task lock every 5 min while the agent works.
// Returns a stop func — call it when the run finishes. Returns nil if taskID is
// invalid or teamStore is nil.
func startTaskLockRenewal(ctx context.Context, teamStore store.TeamStore, taskID, teamID uuid.UUID) (stop func()) {
if taskID == uuid.Nil || teamStore == nil {
return nil
}
ch := make(chan struct{})
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := teamStore.RenewTaskLock(ctx, taskID, teamID); err != nil {
slog.Warn("teammate lock renewal failed", "task_id", taskID, "error", err)
return
}
slog.Debug("teammate lock renewed", "task_id", taskID)
case <-ch:
return
case <-ctx.Done():
return
}
}
}()
return func() { close(ch) }
}
// teammateTaskMeta holds resolved task context for post-turn processing.
type teammateTaskMeta struct {
TaskID uuid.UUID
TeamID uuid.UUID
ToAgent string
Channel string
ChatID string
Subject string
TaskNumber int
}
// resolveTeamTaskOutcome determines the correct task lifecycle action
// after a teammate run: error→fail, completed/escalated→skip, reviewed→renew,
// loopKilled→fail, default→auto-complete.
// Returns the resolved team (for announce routing) and dispatches unblocked tasks.
func resolveTeamTaskOutcome(
ctx context.Context,
deps *ConsumerDeps,
outcome scheduler.RunOutcome,
flags *tools.TaskActionFlags,
meta teammateTaskMeta,
) *store.TeamData {
if meta.TaskID == uuid.Nil || deps.TeamStore == nil {
return nil
}
cachedTeam, _ := deps.TeamStore.GetTeam(ctx, meta.TeamID)
if cachedTeam == nil {
return nil
}
// Check current task status — agent may have already updated it via tool.
currentTask, taskErr := deps.TeamStore.GetTask(ctx, meta.TaskID)
alreadyTerminal := taskErr == nil && currentTask != nil &&
(currentTask.Status == store.TeamTaskStatusCompleted ||
currentTask.Status == store.TeamTaskStatusFailed ||
currentTask.Status == store.TeamTaskStatusCancelled)
if alreadyTerminal {
// Always dispatch unblocked tasks even if already terminal.
if deps.PostTurn != nil {
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
}
return cachedTeam
}
toAgent := meta.ToAgent
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
taskSubject := meta.Subject
taskNumber := meta.TaskNumber
taskChannel := meta.Channel
taskChatID := meta.ChatID
// Enrich with live task data if available.
if currentTask != nil {
if currentTask.Subject != "" {
taskSubject = currentTask.Subject
}
if currentTask.TaskNumber != 0 {
taskNumber = currentTask.TaskNumber
}
if currentTask.Channel != "" {
taskChannel = currentTask.Channel
}
if currentTask.ChatID != "" {
taskChatID = currentTask.ChatID
}
}
// Smart post-turn decision based on action flags.
// Only error, completed/escalated, and reviewed block auto-complete.
switch {
case outcome.Err != nil:
// Agent errored → auto-fail.
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, outcome.Err.Error()); err != nil {
slog.Warn("auto-complete: FailTask error", "task_id", meta.TaskID, "error", err)
} else {
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
meta.TeamID.String(), meta.TaskID.String(),
store.TeamTaskStatusFailed,
"agent", toAgent,
tools.WithTaskInfo(taskNumber, taskSubject),
tools.WithReason(outcome.Err.Error()),
tools.WithChannel(taskChannel),
tools.WithChatID(taskChatID),
tools.WithTimestamp(now),
))
}
case flags.Completed || flags.Escalated:
// Tool already completed/failed the task — skip auto-complete.
slog.Info("post-turn: tool handled task", "task_id", meta.TaskID,
"completed", flags.Completed, "escalated", flags.Escalated)
case flags.Reviewed:
// Task submitted for review — skip auto-complete, renew lock.
_ = deps.TeamStore.RenewTaskLock(ctx, meta.TaskID, meta.TeamID)
slog.Info("post-turn: task submitted for review", "task_id", meta.TaskID)
case outcome.Result != nil && outcome.Result.LoopKilled:
// Loop detector killed the run → auto-fail instead of auto-complete.
// The agent was terminated (not stuck in_progress) — mark as failed
// so the leader sees clear failure signal and can retry directly.
failMsg := outcome.Result.Content
if failMsg == "" {
failMsg = "Agent run terminated by loop detector"
}
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, failMsg); err != nil {
slog.Warn("auto-fail: FailTask error (loop kill)", "task_id", meta.TaskID, "error", err)
} else {
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
meta.TeamID.String(), meta.TaskID.String(),
store.TeamTaskStatusFailed,
"system", "loop_detector",
tools.WithTaskInfo(taskNumber, taskSubject),
tools.WithReason("loop_detector_kill"),
tools.WithChannel(taskChannel),
tools.WithChatID(taskChatID),
tools.WithTimestamp(now),
))
}
slog.Warn("post-turn: loop detector killed member run",
"task_id", meta.TaskID, "agent", toAgent)
default:
// Agent turn ended without terminal action — auto-complete.
// Covers: Progressed, Commented, Claimed, or no flags at all.
if outcome.Result != nil {
result := outcome.Result.Content
if len(outcome.Result.Deliverables) > 0 {
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
}
if len(result) > 100_000 {
result = result[:100_000] + "\n[truncated]"
}
if err := deps.TeamStore.CompleteTask(ctx, meta.TaskID, meta.TeamID, result); err != nil {
slog.Warn("auto-complete: CompleteTask error", "task_id", meta.TaskID, "error", err)
} else {
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
meta.TeamID.String(), meta.TaskID.String(),
store.TeamTaskStatusCompleted,
"agent", toAgent,
tools.WithTaskInfo(taskNumber, taskSubject),
tools.WithOwnerAgentKey(toAgent),
tools.WithChannel(taskChannel),
tools.WithChatID(taskChatID),
tools.WithTimestamp(now),
))
}
}
}
// Always dispatch unblocked tasks after member turn ends,
// regardless of whether the task was already completed by the tool.
// This ensures dependent tasks start only after the member's run finishes.
if deps.PostTurn != nil {
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
}
return cachedTeam
}
+12 -305
View File
@@ -5,15 +5,12 @@ import (
"encoding/json"
"fmt"
"log/slog"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -67,230 +64,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.ChatTitle, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter, req.LightContext)
// 1b. Determine image routing strategy.
// If read_image tool has a dedicated vision provider, images are NOT attached inline
// to the main LLM — the agent calls read_image tool instead. This avoids sending
// images to providers that don't support vision or have strict content filters.
deferToReadImageTool := l.hasReadImageProvider()
// 1b–2f. Persist and enrich all incoming media (images, docs, audio, video).
ctx, messages, mediaRefs := l.enrichInputMedia(ctx, &req, messages)
if !deferToReadImageTool {
// Inline mode: reload historical images directly into messages for main provider.
l.reloadMediaForMessages(messages, maxMediaReloadMessages)
}
// 2. Process media: sanitize images, persist to media store.
var mediaRefs []providers.MediaRef
if len(req.Media) > 0 {
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
// Load current-turn images from persisted refs (Path is always set for new uploads).
var imageFiles []bus.MediaFile
for _, ref := range mediaRefs {
if ref.Kind == "image" && ref.Path != "" {
imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
}
}
if deferToReadImageTool {
// File-ref mode: images primarily accessed via read_image(path=...).
// Still load into context as fallback — if LLM omits the path param,
// read_image can fall back to context images. This costs Go memory
// but NOT LLM tokens (base64 is in Go context, not sent to provider).
if images := loadImages(imageFiles); len(images) > 0 {
ctx = tools.WithMediaImages(ctx, images)
}
slog.Info("vision: file-ref mode, images accessible via read_image tool",
"count", len(imageFiles), "agent", l.id)
} else if images := loadImages(imageFiles); len(images) > 0 {
// Inline mode: read files, base64 encode, attach to message + context.
messages[len(messages)-1].Images = images
ctx = tools.WithMediaImages(ctx, images)
slog.Info("vision: attached images inline to main provider", "count", len(images), "agent", l.id)
}
}
// 2a. Load historical images into context for read_image tool.
// Both modes need this: inline mode for main LLM, file-ref mode as fallback
// when LLM calls read_image without the path param.
if l.mediaStore != nil {
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
}
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
// Historical first, current last — so refs[len-1] is always the most recent file.
var docRefs []providers.MediaRef
for i := len(messages) - 1; i >= 0; i-- {
for _, ref := range messages[i].MediaRefs {
if ref.Kind == "document" {
docRefs = append(docRefs, ref)
}
}
}
for _, ref := range mediaRefs {
if ref.Kind == "document" {
docRefs = append(docRefs, ref)
}
}
if len(docRefs) > 0 {
ctx = tools.WithMediaDocRefs(ctx, docRefs)
// Enrich the last user message with persisted file paths so skills can access
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
l.enrichDocumentPaths(messages, mediaRefs)
}
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
var audioRefs []providers.MediaRef
for i := len(messages) - 1; i >= 0; i-- {
for _, ref := range messages[i].MediaRefs {
if ref.Kind == "audio" {
audioRefs = append(audioRefs, ref)
}
}
}
for _, ref := range mediaRefs {
if ref.Kind == "audio" {
audioRefs = append(audioRefs, ref)
}
}
if len(audioRefs) > 0 {
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
// Embed media IDs into <media:audio> tags so LLM can reference them.
l.enrichAudioIDs(messages, mediaRefs)
}
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
var videoRefs []providers.MediaRef
for i := len(messages) - 1; i >= 0; i-- {
for _, ref := range messages[i].MediaRefs {
if ref.Kind == "video" {
videoRefs = append(videoRefs, ref)
}
}
}
for _, ref := range mediaRefs {
if ref.Kind == "video" {
videoRefs = append(videoRefs, ref)
}
}
if len(videoRefs) > 0 {
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
// Embed media IDs into <media:video> tags so LLM can reference them.
l.enrichVideoIDs(messages, mediaRefs)
}
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
// knows images were received and stored (consistent with audio/video enrichment).
l.enrichImageIDs(messages, mediaRefs)
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
// This enables read_image(path=...) for both current and historical images.
if deferToReadImageTool {
l.enrichImagePaths(messages)
}
// 2f. Collect all media file paths for team workspace auto-collect.
// When the leader calls team_tasks(create), these paths are copied to the
// team workspace so members can access attached files.
if len(mediaRefs) > 0 && l.mediaStore != nil {
var mediaPaths []string
for _, ref := range mediaRefs {
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
if ref.Path != "" {
mediaPaths = append(mediaPaths, ref.Path)
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
mediaPaths = append(mediaPaths, p)
}
}
if len(mediaPaths) > 0 {
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
// Extract original filenames from <media:document name="X" path="Y"> tags
// in the last user message (enriched in step 2b above).
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
ctx = tools.WithRunMediaNames(ctx, nameMap)
}
}
}
}
// 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks.
// Stale recovery (expired lock → pending) is handled by the background TaskTicker.
// Reminders are injected BEFORE the user message so the user's actual message is always
// the last message — prevents trailing assistant messages that proxy providers reject.
if l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "active", req.UserID, "", "", 0, 0); err == nil {
var stale []string
var inProgress []string
for _, t := range tasks {
if t.Status == store.TeamTaskStatusPending {
age := time.Since(t.CreatedAt).Truncate(time.Minute)
stale = append(stale, fmt.Sprintf("- %s: \"%s\" (pending %s)", t.ID, t.Subject, age))
}
if t.Status == store.TeamTaskStatusInProgress {
age := time.Since(t.UpdatedAt).Truncate(time.Minute)
progressInfo := fmt.Sprintf("in progress %s", age)
if t.ProgressPercent > 0 {
if t.ProgressStep != "" {
progressInfo = fmt.Sprintf("%d%% — %s, %s", t.ProgressPercent, t.ProgressStep, age)
} else {
progressInfo = fmt.Sprintf("%d%%, %s", t.ProgressPercent, age)
}
}
inProgress = append(inProgress, fmt.Sprintf("- %s: \"%s\" (%s)", t.ID, t.Subject, progressInfo))
}
}
var parts []string
if len(stale) > 0 {
parts = append(parts, fmt.Sprintf(
"You have %d pending team task(s) awaiting dispatch:\n%s\n"+
"These tasks will be auto-dispatched to available team members. If no longer needed, cancel with team_tasks action=cancel.",
len(stale), strings.Join(stale, "\n")))
}
if len(inProgress) > 0 {
parts = append(parts, fmt.Sprintf(
"You have %d in-progress team task(s) being handled by team members:\n%s\n"+
"Their results will arrive automatically. Do NOT cancel, re-create, or re-spawn these tasks.",
len(inProgress), strings.Join(inProgress, "\n")))
}
if len(parts) > 0 {
reminder := "[System] " + strings.Join(parts, "\n\n")
// Pop user message, inject reminder, push user message back
userMsg := messages[len(messages)-1]
messages = messages[:len(messages)-1]
messages = append(messages,
providers.Message{Role: "user", Content: reminder},
providers.Message{Role: "assistant", Content: "I see the task status. Let me handle accordingly."},
userMsg,
)
}
}
}
}
// 2h. Member task reminder: inject task context for members working on dispatched tasks.
// Caches task subject/number for mid-loop progress nudge (avoids extra DB query).
var memberTaskSubject string
var memberTaskNumber int
if req.TeamTaskID != "" && l.teamStore != nil {
if taskUUID, err := uuid.Parse(req.TeamTaskID); err == nil {
if task, err := l.teamStore.GetTask(ctx, taskUUID); err == nil && task != nil {
memberTaskSubject = task.Subject
memberTaskNumber = task.TaskNumber
reminder := fmt.Sprintf(
"[System] You are working on team task #%d: %q. "+
"Stay focused on this task. Your final response becomes the task result — make it clear and complete. "+
"For long tasks, report progress: team_tasks(action=\"progress\", percent=50, text=\"status\").",
task.TaskNumber, task.Subject)
// Pop user message, inject reminder, push user message back
userMsg := messages[len(messages)-1]
messages = messages[:len(messages)-1]
messages = append(messages,
providers.Message{Role: "user", Content: reminder},
providers.Message{Role: "assistant", Content: "Understood. I'll focus on this task and report progress."},
userMsg,
)
}
}
}
// 2g–2h. Inject leader pending-task reminders and member task context.
messages, memberTask := l.injectTeamTaskReminders(ctx, &req, messages)
// 3. Buffer new messages — write to session only AFTER the run completes.
// This prevents concurrent runs from seeing each other's in-progress messages.
@@ -320,8 +98,6 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
pendingMsgs: initPendingMsgs,
}
// Member progress nudge: remind dispatched members to report progress (every 6 iterations).
// Inject retry hook so channels can update placeholder on LLM retries.
ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, err error) {
emitRun(AgentEvent{
@@ -381,7 +157,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Member progress nudge: remind to report progress every 6 iterations.
// Suggests percent based on iteration ratio — model can adjust but has a baseline.
if req.TeamTaskID != "" && memberTaskSubject != "" && rs.iteration > 0 && rs.iteration%6 == 0 {
if req.TeamTaskID != "" && memberTask.Subject != "" && rs.iteration > 0 && rs.iteration%6 == 0 {
var nudge string
if maxIter > 0 {
suggestedPct := rs.iteration * 100 / maxIter
@@ -389,13 +165,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
"[System] You are at iteration %d/%d (~%d%% of budget) working on task #%d: %q. "+
"Report your progress now: team_tasks(action=\"progress\", percent=%d, text=\"what you've accomplished so far\"). "+
"Adjust percent based on actual work completed.",
rs.iteration, maxIter, suggestedPct, memberTaskNumber, memberTaskSubject, suggestedPct)
rs.iteration, maxIter, suggestedPct, memberTask.TaskNumber, memberTask.Subject, suggestedPct)
} else {
nudge = fmt.Sprintf(
"[System] You are at iteration %d working on task #%d: %q. "+
"Report your progress now: team_tasks(action=\"progress\", percent=50, text=\"what you've accomplished so far\"). "+
"Adjust percent based on actual work completed.",
rs.iteration, memberTaskNumber, memberTaskSubject)
rs.iteration, memberTask.TaskNumber, memberTask.Subject)
}
messages = append(messages, providers.Message{Role: "user", Content: nudge})
}
@@ -423,81 +199,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
Payload: map[string]any{"phase": "thinking", "iteration": rs.iteration},
})
// Build provider request with policy-filtered tools
// Build per-iteration tool list: policy, tenant exclusions, bootstrap, skill visibility,
// channel type, and final-iteration stripping.
var toolDefs []providers.ToolDefinition
var allowedTools map[string]bool
if l.toolPolicy != nil {
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, req.ToolAllow, false, false)
allowedTools = make(map[string]bool, len(toolDefs))
for _, td := range toolDefs {
allowedTools[td.Function.Name] = true
}
} else {
toolDefs = l.tools.ProviderDefs()
}
// Per-tenant tool exclusions: remove tools disabled for this agent's tenant.
if len(l.disabledTools) > 0 {
filtered := toolDefs[:0]
for _, td := range toolDefs {
if !l.disabledTools[td.Function.Name] {
filtered = append(filtered, td)
} else {
delete(allowedTools, td.Function.Name)
}
}
toolDefs = filtered
}
// Bootstrap mode: restrict API tool definitions to write_file only (open agents).
// Predefined agents keep all tools — BOOTSTRAP.md guides behavior.
if hadBootstrap && l.agentType != store.AgentTypePredefined {
var bootstrapDefs []providers.ToolDefinition
for _, td := range toolDefs {
if bootstrapToolAllowlist[td.Function.Name] {
bootstrapDefs = append(bootstrapDefs, td)
}
}
toolDefs = bootstrapDefs
}
// Hide skill_manage from LLM when skill_evolve is off.
// Tool stays in the registry (shared) but won't appear in API tool definitions.
if !l.skillEvolve {
filtered := toolDefs[:0:0]
for _, td := range toolDefs {
if td.Function.Name != "skill_manage" {
filtered = append(filtered, td)
}
}
toolDefs = filtered
}
// Hide channel-specific tools when channel type doesn't match.
if req.ChannelType != "" {
filtered := toolDefs[:0:0]
for _, td := range toolDefs {
if tool, ok := l.tools.Get(td.Function.Name); ok {
if ca, ok := tool.(tools.ChannelAware); ok {
if !slices.Contains(ca.RequiredChannelTypes(), req.ChannelType) {
continue
}
}
}
filtered = append(filtered, td)
}
toolDefs = filtered
}
// Final iteration: strip all tools to force a text-only response.
// Without this the model may keep requesting tools and exit with "...".
if rs.iteration == maxIter {
toolDefs = nil
messages = append(messages, providers.Message{
Role: "user",
Content: "[System] Final iteration reached. Summarize all findings and respond to the user now. No more tool calls allowed.",
})
}
toolDefs, allowedTools, messages = l.buildFilteredTools(&req, hadBootstrap, rs.iteration, maxIter, messages)
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
model := l.model
@@ -649,7 +355,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
continue
}
// No tool calls → done
// No tool calls — exit or drain injected follow-ups.
if len(resp.ToolCalls) == 0 {
// Mid-run injection (Point B): drain all buffered user follow-up messages
// before exiting. If found, save current assistant response and continue
@@ -808,6 +514,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
} else {
// Multiple tools: parallel execution via goroutines.
// Each goroutine performs lazy MCP activation + policy checking independently.
// Tool instances are immutable (context-based) so concurrent access is safe.
// Results are collected then processed sequentially for deterministic ordering.
type indexedResult struct {
+138
View File
@@ -0,0 +1,138 @@
package agent
import (
"context"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// collectRefsByKind gathers MediaRefs of a given kind from message history
// (reverse order) and current-turn refs. Historical first, current last.
func collectRefsByKind(messages []providers.Message, currentRefs []providers.MediaRef, kind string) []providers.MediaRef {
var refs []providers.MediaRef
for i := len(messages) - 1; i >= 0; i-- {
for _, ref := range messages[i].MediaRefs {
if ref.Kind == kind {
refs = append(refs, ref)
}
}
}
for _, ref := range currentRefs {
if ref.Kind == kind {
refs = append(refs, ref)
}
}
return refs
}
// enrichInputMedia processes incoming media (images, documents, audio, video),
// persists them, enriches messages with media tags, and populates context
// with refs for tool access. Returns updated context, modified messages, and current-turn media refs.
func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages []providers.Message) (context.Context, []providers.Message, []providers.MediaRef) {
// 1b. Determine image routing strategy.
// If read_image tool has a dedicated vision provider, images are NOT attached inline
// to the main LLM — the agent calls read_image tool instead. This avoids sending
// images to providers that don't support vision or have strict content filters.
deferToReadImageTool := l.hasReadImageProvider()
if !deferToReadImageTool {
// Inline mode: reload historical images directly into messages for main provider.
l.reloadMediaForMessages(messages, maxMediaReloadMessages)
}
// 2. Process media: sanitize images, persist to media store.
var mediaRefs []providers.MediaRef
if len(req.Media) > 0 {
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
// Load current-turn images from persisted refs (Path is always set for new uploads).
var imageFiles []bus.MediaFile
for _, ref := range mediaRefs {
if ref.Kind == "image" && ref.Path != "" {
imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
}
}
if deferToReadImageTool {
// File-ref mode: images primarily accessed via read_image(path=...).
// Still load into context as fallback — if LLM omits the path param,
// read_image can fall back to context images. This costs Go memory
// but NOT LLM tokens (base64 is in Go context, not sent to provider).
if images := loadImages(imageFiles); len(images) > 0 {
ctx = tools.WithMediaImages(ctx, images)
}
slog.Info("vision: file-ref mode, images accessible via read_image tool",
"count", len(imageFiles), "agent", l.id)
} else if images := loadImages(imageFiles); len(images) > 0 {
// Inline mode: read files, base64 encode, attach to message + context.
messages[len(messages)-1].Images = images
ctx = tools.WithMediaImages(ctx, images)
slog.Info("vision: attached images inline to main provider", "count", len(images), "agent", l.id)
}
}
// 2a. Load historical images into context for read_image tool.
// Both modes need this: inline mode for main LLM, file-ref mode as fallback
// when LLM calls read_image without the path param.
if l.mediaStore != nil {
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
}
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
if docRefs := collectRefsByKind(messages, mediaRefs, "document"); len(docRefs) > 0 {
ctx = tools.WithMediaDocRefs(ctx, docRefs)
// Enrich the last user message with persisted file paths so skills can access
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
l.enrichDocumentPaths(messages, mediaRefs)
}
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
if audioRefs := collectRefsByKind(messages, mediaRefs, "audio"); len(audioRefs) > 0 {
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
l.enrichAudioIDs(messages, mediaRefs)
}
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
if videoRefs := collectRefsByKind(messages, mediaRefs, "video"); len(videoRefs) > 0 {
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
l.enrichVideoIDs(messages, mediaRefs)
}
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
// knows images were received and stored (consistent with audio/video enrichment).
l.enrichImageIDs(messages, mediaRefs)
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
// This enables read_image(path=...) for both current and historical images.
if deferToReadImageTool {
l.enrichImagePaths(messages)
}
// 2f. Collect all media file paths for team workspace auto-collect.
// When the leader calls team_tasks(create), these paths are copied to the
// team workspace so members can access attached files.
if len(mediaRefs) > 0 && l.mediaStore != nil {
var mediaPaths []string
for _, ref := range mediaRefs {
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
if ref.Path != "" {
mediaPaths = append(mediaPaths, ref.Path)
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
mediaPaths = append(mediaPaths, p)
}
}
if len(mediaPaths) > 0 {
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
// Extract original filenames from <media:document name="X" path="Y"> tags
// in the last user message (enriched in step 2b above).
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
ctx = tools.WithRunMediaNames(ctx, nameMap)
}
}
}
}
return ctx, messages, mediaRefs
}
+106
View File
@@ -0,0 +1,106 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// memberTaskInfo holds cached task metadata for mid-loop progress nudges.
type memberTaskInfo struct {
Subject string
TaskNumber int
}
// injectTeamTaskReminders adds leader pending-task reminders and
// member progress context to the message list before the first LLM call.
// Returns updated messages and member task info (for progress nudges).
func (l *Loop) injectTeamTaskReminders(ctx context.Context, req *RunRequest, messages []providers.Message) ([]providers.Message, memberTaskInfo) {
// 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks.
// Stale recovery (expired lock → pending) is handled by the background TaskTicker.
// Reminders are injected BEFORE the user message so the user's actual message is always
// the last message — prevents trailing assistant messages that proxy providers reject.
if l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "active", req.UserID, "", "", 0, 0); err == nil {
var stale []string
var inProgress []string
for _, t := range tasks {
if t.Status == store.TeamTaskStatusPending {
age := time.Since(t.CreatedAt).Truncate(time.Minute)
stale = append(stale, fmt.Sprintf("- %s: \"%s\" (pending %s)", t.ID, t.Subject, age))
}
if t.Status == store.TeamTaskStatusInProgress {
age := time.Since(t.UpdatedAt).Truncate(time.Minute)
progressInfo := fmt.Sprintf("in progress %s", age)
if t.ProgressPercent > 0 {
if t.ProgressStep != "" {
progressInfo = fmt.Sprintf("%d%% — %s, %s", t.ProgressPercent, t.ProgressStep, age)
} else {
progressInfo = fmt.Sprintf("%d%%, %s", t.ProgressPercent, age)
}
}
inProgress = append(inProgress, fmt.Sprintf("- %s: \"%s\" (%s)", t.ID, t.Subject, progressInfo))
}
}
var parts []string
if len(stale) > 0 {
parts = append(parts, fmt.Sprintf(
"You have %d pending team task(s) awaiting dispatch:\n%s\n"+
"These tasks will be auto-dispatched to available team members. If no longer needed, cancel with team_tasks action=cancel.",
len(stale), strings.Join(stale, "\n")))
}
if len(inProgress) > 0 {
parts = append(parts, fmt.Sprintf(
"You have %d in-progress team task(s) being handled by team members:\n%s\n"+
"Their results will arrive automatically. Do NOT cancel, re-create, or re-spawn these tasks.",
len(inProgress), strings.Join(inProgress, "\n")))
}
if len(parts) > 0 {
reminder := "[System] " + strings.Join(parts, "\n\n")
// Pop user message, inject reminder, push user message back
userMsg := messages[len(messages)-1]
messages = messages[:len(messages)-1]
messages = append(messages,
providers.Message{Role: "user", Content: reminder},
providers.Message{Role: "assistant", Content: "I see the task status. Let me handle accordingly."},
userMsg,
)
}
}
}
}
// 2h. Member task reminder: inject task context for members working on dispatched tasks.
// Caches task subject/number for mid-loop progress nudge (avoids extra DB query).
var info memberTaskInfo
if req.TeamTaskID != "" && l.teamStore != nil {
if taskUUID, err := uuid.Parse(req.TeamTaskID); err == nil {
if task, err := l.teamStore.GetTask(ctx, taskUUID); err == nil && task != nil {
info.Subject = task.Subject
info.TaskNumber = task.TaskNumber
reminder := fmt.Sprintf(
"[System] You are working on team task #%d: %q. "+
"Stay focused on this task. Your final response becomes the task result — make it clear and complete. "+
"For long tasks, report progress: team_tasks(action=\"progress\", percent=50, text=\"status\").",
task.TaskNumber, task.Subject)
// Pop user message, inject reminder, push user message back
userMsg := messages[len(messages)-1]
messages = messages[:len(messages)-1]
messages = append(messages,
providers.Message{Role: "user", Content: reminder},
providers.Message{Role: "assistant", Content: "Understood. I'll focus on this task and report progress."},
userMsg,
)
}
}
}
return messages, info
}
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"slices"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// buildFilteredTools resolves the per-iteration tool definitions based on policy,
// disabled tools, bootstrap mode, skill visibility, channel type, and iteration budget.
// Returns tool definitions for the provider, an allowed-tools map for execution validation,
// and the (potentially modified) messages slice when final-iteration stripping appends a hint.
func (l *Loop) buildFilteredTools(req *RunRequest, hadBootstrap bool, iteration, maxIter int, messages []providers.Message) ([]providers.ToolDefinition, map[string]bool, []providers.Message) {
// Build provider request with policy-filtered tools.
var toolDefs []providers.ToolDefinition
var allowedTools map[string]bool
if l.toolPolicy != nil {
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, req.ToolAllow, false, false)
allowedTools = make(map[string]bool, len(toolDefs))
for _, td := range toolDefs {
allowedTools[td.Function.Name] = true
}
} else {
toolDefs = l.tools.ProviderDefs()
}
// Per-tenant tool exclusions: remove tools disabled for this agent's tenant.
if len(l.disabledTools) > 0 {
filtered := toolDefs[:0]
for _, td := range toolDefs {
if !l.disabledTools[td.Function.Name] {
filtered = append(filtered, td)
} else {
delete(allowedTools, td.Function.Name)
}
}
toolDefs = filtered
}
// Bootstrap mode: restrict API tool definitions to write_file only (open agents).
// Predefined agents keep all tools — BOOTSTRAP.md guides behavior.
if hadBootstrap && l.agentType != store.AgentTypePredefined {
var bootstrapDefs []providers.ToolDefinition
for _, td := range toolDefs {
if bootstrapToolAllowlist[td.Function.Name] {
bootstrapDefs = append(bootstrapDefs, td)
}
}
toolDefs = bootstrapDefs
}
// Hide skill_manage from LLM when skill_evolve is off.
// Tool stays in the registry (shared) but won't appear in API tool definitions.
if !l.skillEvolve {
filtered := toolDefs[:0:0]
for _, td := range toolDefs {
if td.Function.Name != "skill_manage" {
filtered = append(filtered, td)
}
}
toolDefs = filtered
}
// Hide channel-specific tools when channel type doesn't match.
if req.ChannelType != "" {
filtered := toolDefs[:0:0]
for _, td := range toolDefs {
if tool, ok := l.tools.Get(td.Function.Name); ok {
if ca, ok := tool.(tools.ChannelAware); ok {
if !slices.Contains(ca.RequiredChannelTypes(), req.ChannelType) {
continue
}
}
}
filtered = append(filtered, td)
}
toolDefs = filtered
}
// Final iteration: strip all tools to force a text-only response.
// Without this the model may keep requesting tools and exit with "...".
if iteration == maxIter {
toolDefs = nil
messages = append(messages, providers.Message{
Role: "user",
Content: "[System] Final iteration reached. Summarize all findings and respond to the user now. No more tool calls allowed.",
})
}
return toolDefs, allowedTools, messages
}
+1 -1
View File
@@ -86,7 +86,7 @@ type Loop struct {
eventPub bus.EventPublisher // currently unused by Loop; kept for future use
sessions store.SessionStore
tools *tools.Registry
tools tools.ToolExecutor
toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions)
activeRuns atomic.Int32 // number of currently executing runs
+118 -83
View File
@@ -148,6 +148,120 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
// resolvedServer holds a server config with merged credentials ready for connection.
type resolvedServer struct {
info store.MCPAccessInfo
args []string
env map[string]string
headers map[string]string
hasUserCreds bool
}
// resolveServerCredentials merges server defaults with per-user credentials.
// Returns nil if the server should be skipped (disabled or missing required creds).
func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAccessInfo, userID string) *resolvedServer {
srv := info.Server
if !srv.Enabled {
return nil
}
// Skip server if it requires per-user credentials and user has none
if requireUserCreds(srv.Settings) {
if userID == "" {
return nil
}
uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID)
if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) {
slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID)
return nil
}
}
args := jsonBytesToStringSlice(srv.Args)
env := jsonBytesToStringMap(srv.Env)
headers := jsonBytesToStringMap(srv.Headers)
// Inject APIKey into headers if present (bug fix: was never passed to connections)
if srv.APIKey != "" && headers["Authorization"] == "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + srv.APIKey
}
// Merge per-user credentials (user overrides server defaults)
if userID != "" && m.store != nil {
if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil {
if userCreds.APIKey != "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + userCreds.APIKey
}
for k, v := range userCreds.Headers {
if headers == nil {
headers = make(map[string]string)
}
headers[k] = v
}
for k, v := range userCreds.Env {
if env == nil {
env = make(map[string]string)
}
env[k] = v
}
}
}
// Per-user credentials change connection params → can't share pool connection.
// Fall back to per-agent mode when user has custom credentials.
hasUserCreds := userID != "" && m.store != nil
if hasUserCreds {
if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) {
hasUserCreds = true
} else {
hasUserCreds = false
}
}
return &resolvedServer{
info: info,
args: args,
env: env,
headers: headers,
hasUserCreds: hasUserCreds,
}
}
// connectAndFilter establishes the MCP connection (pool or per-agent mode)
// and applies tool allow/deny filtering from server grants.
func (m *Manager) connectAndFilter(ctx context.Context, rs *resolvedServer) error {
srv := rs.info.Server
if m.pool != nil && !rs.hasUserCreds {
// Pool mode: acquire shared connection, create per-agent BridgeTools
tid := store.TenantIDFromContext(ctx)
if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec); err != nil {
return err
}
} else {
// Per-agent mode: create per-agent connection
if err := m.connectServer(ctx, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers,
srv.ToolPrefix, srv.TimeoutSec); err != nil {
return err
}
}
// Apply tool filtering from grants
if len(rs.info.ToolAllow) > 0 || len(rs.info.ToolDeny) > 0 {
m.filterTools(srv.Name, rs.info.ToolAllow, rs.info.ToolDeny)
}
return nil
}
// LoadForAgent connects MCP servers accessible by a specific agent+user.
// Previously registered MCP tools for this manager are cleared and reloaded.
func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID string) error {
@@ -164,91 +278,12 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st
m.unregisterAllTools()
for _, info := range accessible {
srv := info.Server
if !srv.Enabled {
rs := m.resolveServerCredentials(ctx, info, userID)
if rs == nil {
continue
}
// Skip server if it requires per-user credentials and user has none
if requireUserCreds(srv.Settings) {
if userID == "" {
continue
}
uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID)
if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) {
slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID)
continue
}
}
args := jsonBytesToStringSlice(srv.Args)
env := jsonBytesToStringMap(srv.Env)
headers := jsonBytesToStringMap(srv.Headers)
// Inject APIKey into headers if present (bug fix: was never passed to connections)
if srv.APIKey != "" && headers["Authorization"] == "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + srv.APIKey
}
// Merge per-user credentials (user overrides server defaults)
if userID != "" && m.store != nil {
if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil {
if userCreds.APIKey != "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + userCreds.APIKey
}
for k, v := range userCreds.Headers {
if headers == nil {
headers = make(map[string]string)
}
headers[k] = v
}
for k, v := range userCreds.Env {
if env == nil {
env = make(map[string]string)
}
env[k] = v
}
}
}
// Per-user credentials change connection params → can't share pool connection.
// Fall back to per-agent mode when user has custom credentials.
hasUserCreds := userID != "" && m.store != nil
if hasUserCreds {
if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) {
hasUserCreds = true
} else {
hasUserCreds = false
}
}
if m.pool != nil && !hasUserCreds {
// Pool mode: acquire shared connection, create per-agent BridgeTools
tid := store.TenantIDFromContext(ctx)
if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command,
args, env, srv.URL, headers, srv.ToolPrefix, srv.TimeoutSec); err != nil {
slog.Warn("mcp.server.connect_failed", "server", srv.Name, "error", err)
continue
}
} else {
// Per-agent mode: create per-agent connection
if err := m.connectServer(ctx, srv.Name, srv.Transport, srv.Command,
args, env, srv.URL, headers,
srv.ToolPrefix, srv.TimeoutSec); err != nil {
slog.Warn("mcp.server.connect_failed", "server", srv.Name, "error", err)
continue
}
}
// Apply tool filtering from grants
if len(info.ToolAllow) > 0 || len(info.ToolDeny) > 0 {
m.filterTools(srv.Name, info.ToolAllow, info.ToolDeny)
if err := m.connectAndFilter(ctx, rs); err != nil {
slog.Warn("mcp.server.connect_failed", "server", info.Server.Name, "error", err)
}
}
+6 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -261,13 +262,11 @@ func (t *TaskTicker) broadcastStaleEvents(ctx context.Context, tasks []store.Rec
continue
}
seen[task.TeamID] = true
bus.BroadcastForTenant(t.msgBus, protocol.EventTeamTaskStale, task.TenantID, protocol.TeamTaskEventPayload{
TeamID: task.TeamID.String(),
Status: store.TeamTaskStatusStale,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "task_ticker",
})
bus.BroadcastForTenant(t.msgBus, protocol.EventTeamTaskStale, task.TenantID, tools.BuildTaskEventPayload(
task.TeamID.String(), "",
store.TeamTaskStatusStale,
"system", "task_ticker",
))
}
}
+21
View File
@@ -0,0 +1,21 @@
package tools
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// ToolExecutor abstracts tool execution for dependency inversion.
// Production uses *Registry; tests can inject a mock.
type ToolExecutor interface {
ExecuteWithContext(ctx context.Context, name string, args map[string]any, channel, chatID, peerKind, sessionKey string, asyncCB AsyncCallback) *Result
TryActivateDeferred(name string) bool
ProviderDefs() []providers.ToolDefinition
Get(name string) (Tool, bool)
List() []string
Aliases() map[string]string
}
// Compile-time check: *Registry satisfies ToolExecutor.
var _ ToolExecutor = (*Registry)(nil)
+1 -1
View File
@@ -100,7 +100,7 @@ func NewPolicyEngine(cfg *config.ToolsConfig) *PolicyEngine {
// FilterTools returns only the tools allowed by the policy for the given context.
// It evaluates the 7-step pipeline and returns filtered provider definitions.
func (pe *PolicyEngine) FilterTools(
registry *Registry,
registry ToolExecutor,
agentID string,
providerName string,
agentToolPolicy *config.ToolPolicySpec,
+9 -9
View File
@@ -56,19 +56,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, roster)
announceMeta := map[string]string{
"origin_channel": task.OriginChannel,
"origin_peer_kind": task.OriginPeerKind,
"parent_agent": task.ParentID,
"subagent_id": task.ID,
"subagent_label": task.Label,
"origin_trace_id": task.OriginTraceID.String(),
"origin_root_span_id": task.OriginRootSpanID.String(),
MetaOriginChannel: task.OriginChannel,
MetaOriginPeerKind: task.OriginPeerKind,
MetaParentAgent: task.ParentID,
"subagent_id": task.ID,
MetaSubagentLabel: task.Label,
MetaOriginTraceID: task.OriginTraceID.String(),
MetaOriginRootSpanID: task.OriginRootSpanID.String(),
}
if task.OriginLocalKey != "" {
announceMeta["origin_local_key"] = task.OriginLocalKey
announceMeta[MetaOriginLocalKey] = task.OriginLocalKey
}
if task.OriginSessionKey != "" {
announceMeta["origin_session_key"] = task.OriginSessionKey
announceMeta[MetaOriginSessionKey] = task.OriginSessionKey
}
sm.msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
+120
View File
@@ -0,0 +1,120 @@
package tools
import (
"context"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// TaskEventOption customizes optional fields on a TeamTaskEventPayload.
type TaskEventOption func(*protocol.TeamTaskEventPayload)
// BuildTaskEventPayload constructs a TeamTaskEventPayload with required fields
// (teamID, taskID, status, actor) and a UTC timestamp. Optional fields are set
// via TaskEventOption functions.
func BuildTaskEventPayload(
teamID, taskID string,
status string,
actorType, actorID string,
opts ...TaskEventOption,
) protocol.TeamTaskEventPayload {
p := protocol.TeamTaskEventPayload{
TeamID: teamID,
TaskID: taskID,
Status: status,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: actorType,
ActorID: actorID,
}
for _, opt := range opts {
opt(&p)
}
return p
}
// WithTaskNumber sets TaskNumber on the payload.
func WithTaskNumber(n int) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.TaskNumber = n }
}
// WithSubject sets Subject on the payload.
func WithSubject(s string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.Subject = s }
}
// WithTaskInfo sets both TaskNumber and Subject.
func WithTaskInfo(taskNumber int, subject string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) {
p.TaskNumber = taskNumber
p.Subject = subject
}
}
// WithOwnerAgentKey sets OwnerAgentKey on the payload.
func WithOwnerAgentKey(k string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.OwnerAgentKey = k }
}
// WithOwnerDisplayName sets OwnerDisplayName on the payload.
func WithOwnerDisplayName(n string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.OwnerDisplayName = n }
}
// WithOwner sets both OwnerAgentKey and OwnerDisplayName.
func WithOwner(agentKey, displayName string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) {
p.OwnerAgentKey = agentKey
p.OwnerDisplayName = displayName
}
}
// WithReason sets Reason on the payload.
func WithReason(r string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.Reason = r }
}
// WithUserID sets UserID on the payload.
func WithUserID(id string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.UserID = id }
}
// WithChannel sets Channel on the payload.
func WithChannel(ch string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.Channel = ch }
}
// WithChatID sets ChatID on the payload.
func WithChatID(id string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.ChatID = id }
}
// WithCommentText sets CommentText on the payload.
func WithCommentText(t string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.CommentText = t }
}
// WithProgress sets ProgressPercent and ProgressStep on the payload.
func WithProgress(percent int, step string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) {
p.ProgressPercent = percent
p.ProgressStep = step
}
}
// WithContextInfo extracts UserID, Channel, and ChatID from the context
// using standard tool context accessors.
func WithContextInfo(ctx context.Context) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) {
p.UserID = store.UserIDFromContext(ctx)
p.Channel = ToolChannelFromCtx(ctx)
p.ChatID = ToolChatIDFromCtx(ctx)
}
}
// WithTimestamp overrides the auto-generated UTC timestamp. Use only when
// the event timestamp must differ from time.Now() (e.g. task creation time).
func WithTimestamp(ts string) TaskEventOption {
return func(p *protocol.TeamTaskEventPayload) { p.Timestamp = ts }
}
+39
View File
@@ -0,0 +1,39 @@
package tools
// Message metadata keys used in dispatch → consumer communication.
// These keys appear in bus.InboundMessage.Metadata for teammate and
// subagent messages routed through the gateway consumer.
const (
MetaOriginChannel = "origin_channel"
MetaOriginPeerKind = "origin_peer_kind"
MetaOriginChatID = "origin_chat_id"
MetaOriginUserID = "origin_user_id"
MetaOriginLocalKey = "origin_local_key"
MetaOriginSessionKey = "origin_session_key"
MetaOriginTraceID = "origin_trace_id"
MetaOriginRootSpanID = "origin_root_span_id"
MetaFromAgent = "from_agent"
MetaToAgent = "to_agent"
MetaToAgentDisplay = "to_agent_display"
MetaTeamTaskID = "team_task_id"
MetaTeamID = "team_id"
MetaTeamWorkspace = "team_workspace"
MetaLeaderAgentID = "leader_agent_id"
MetaParentAgent = "parent_agent"
MetaSubagentLabel = "subagent_label"
MetaCommand = "command"
MetaIsForum = "is_forum"
MetaMessageThreadID = "message_thread_id"
)
// Task metadata keys stored in store.TeamTaskData.Metadata.
// Written during task creation/dispatch, read during deferred dispatch
// and trace context restoration.
const (
TaskMetaPeerKind = "peer_kind"
TaskMetaLocalKey = "local_key"
TaskMetaOriginSession = "origin_session_key"
TaskMetaOriginTrace = "origin_trace_id"
TaskMetaOriginRootSpan = "origin_root_span_id"
TaskMetaTeamWorkspace = "team_workspace"
)
+11 -16
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
@@ -46,21 +45,17 @@ func (t *TeamTasksTool) handleBlockerComment(
// 2. Notify subscriber → "❌ Task failed" → chat channel (direct outbound)
// 3. WS broadcast → web UI dashboard real-time update
memberKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusFailed,
Reason: reason,
OwnerAgentKey: memberKey,
UserID: store.UserIDFromContext(ctx),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: memberKey,
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusFailed,
"agent", memberKey,
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(memberKey),
WithReason(reason),
WithUserID(store.UserIDFromContext(ctx)),
WithChannel(task.Channel),
WithChatID(task.ChatID),
))
// Escalate to leader if enabled in team settings.
escalationCfg := ParseBlockerEscalationConfig(team.Settings)
+318
View File
@@ -0,0 +1,318 @@
package tools
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
// Determine if caller is a lead or a member.
isLead := agentID == team.LeadAgentID
channel := ToolChannelFromCtx(ctx)
if channel == ChannelTeammate || channel == ChannelSystem {
isLead = true // system/teammate channels act on behalf of the lead
}
taskType, _ := args["task_type"].(string)
if taskType == "" {
taskType = "general"
}
if !isLead {
// Members may only create "request" tasks when the feature is enabled.
memberCfg := ParseMemberRequestConfig(team.Settings)
if !memberCfg.Enabled {
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
}
if taskType != "request" {
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
}
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
}
subject, _ := args["subject"].(string)
if subject == "" {
return ErrorResult("subject is required for create action")
}
description, _ := args["description"].(string)
priority := 0
if p, ok := args["priority"].(float64); ok {
priority = int(p)
}
var blockedBy []uuid.UUID
if raw, ok := args["blocked_by"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
}
blockedBy = append(blockedBy, id)
}
}
}
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.Store().GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
if depTask.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
}
switch depTask.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Do not block on finished tasks — create this task without blocked_by, "+
"or pass the completed task's result in the description instead.",
depID, depTask.Subject, depTask.Status))
}
}
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
assigneeKey, _ := args["assignee"].(string)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
// Verify assignee is a member of this team.
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == assigneeID {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
}
// Prevent lead from self-assigning — causes dual-session execution + loop.
// buildCreateHint already hides the lead from the member list hint,
// but weaker models may still attempt self-assignment.
if assigneeID == team.LeadAgentID {
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
}
requireApproval, _ := args["require_approval"].(bool)
status := store.TeamTaskStatusPending
if requireApproval {
status = store.TeamTaskStatusInReview
} else if len(blockedBy) > 0 {
status = store.TeamTaskStatusBlocked
}
// Assigned tasks without blockers stay pending — dispatched after the turn
// ends via post-turn processing (avoids race with blocked_by setup).
// Member requests without auto_dispatch stay pending for leader review.
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
chatID := ToolChatIDFromCtx(ctx)
// Compute team workspace via layered pipeline: tenant → team → user/chat.
shared := IsSharedWorkspace(team.Settings)
taskMeta := make(map[string]any)
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
TeamLayer(team.ID),
UserChatLayer(chatID, shared),
)
taskMeta[TaskMetaTeamWorkspace] = teamWsDir
// Auto-collect media files from current run to team workspace.
// When leader received files from user and creates a task, copy those
// files to the team workspace so members can access them via read_file.
// Also rewrite any media paths in the description to point to the workspace copy,
// since members can't access the original .media/ paths outside their workspace.
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
if wsDir, _ := taskMeta[TaskMetaTeamWorkspace].(string); wsDir != "" {
nameMap := RunMediaNamesFromCtx(ctx)
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
// Store as []any so type assertion works both before and after JSON round-trip.
files := make([]any, len(copiedPaths))
for i, p := range copiedPaths {
files[i] = p
}
taskMeta["attached_files"] = files
// Rewrite media paths in description so members see workspace paths.
for i, src := range mediaPaths {
if i < len(copiedPaths) {
description = strings.ReplaceAll(description, src, copiedPaths[i])
}
}
}
}
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
for i, id := range blockedBy {
ids[i] = id.String()
}
taskMeta["original_blocked_by"] = ids
}
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
taskMeta[TaskMetaPeerKind] = pk
}
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
taskMeta[TaskMetaLocalKey] = lk
}
// Store origin session key so deferred dispatches route announces correctly.
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
taskMeta[TaskMetaOriginSession] = sk
}
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
taskMeta[TaskMetaOriginTrace] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
taskMeta[TaskMetaOriginRootSpan] = rootSpanID.String()
}
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: status,
BlockedBy: blockedBy,
Priority: priority,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: taskType,
CreatedByAgentID: &agentID,
ChatID: chatID,
Metadata: taskMeta,
}
task.OwnerAgentID = &assigneeID
// Auto-link member request to the member's current task as parent.
if !isLead && taskType == "request" {
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
task.ParentID = &parentUUID
}
}
}
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
// Persist media files copied during task creation as DB attachments,
// so the UI and queries see them in team_task_attachments table.
// (Members get auto-attached via WorkspaceInterceptor, but leaders
// don't run inside a task context — handle explicitly here.)
if files, ok := taskMeta["attached_files"].([]any); ok {
for _, f := range files {
filePath, ok := f.(string)
if !ok || filePath == "" {
continue
}
var fileSize int64
if info, err := os.Stat(filePath); err == nil {
fileSize = info.Size()
}
att := &store.TeamTaskAttachmentData{
TaskID: task.ID,
TeamID: team.ID,
ChatID: chatID,
Path: filePath,
FileSize: fileSize,
MimeType: mimeFromExt(filepath.Ext(filePath)),
CreatedByAgentID: &agentID,
}
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
}
}
}
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
team.ID.String(), task.ID.String(),
status,
"agent", agentKey,
WithSubject(subject),
WithContextInfo(ctx),
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
))
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
team.ID.String(), task.ID.String(),
store.TeamTaskStatusInProgress,
"system", "fallback_dispatch",
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(t.manager.AgentKeyFromID(ctx, assigneeID)),
WithChannel(task.Channel),
WithChatID(task.ChatID),
))
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
}
}
}
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
if assigneeName == "" {
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
}
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
// Soft guardrail: warn if subject suggests multiple deliverables.
// Only checks subject (not description) — detailed descriptions are fine.
if subject != "" {
subjLower := strings.ToLower(subject)
hasCompound := strings.Contains(subjLower, " and ") &&
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
if hasCompound {
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
}
}
return NewResult(msg)
}
+11 -32
View File
@@ -10,12 +10,7 @@ import (
)
func (t *TeamTasksTool) executeAskUser(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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -66,12 +61,7 @@ func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeClearAskUser(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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -97,7 +87,7 @@ func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string
}
func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -105,11 +95,6 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
@@ -140,20 +125,14 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
return ErrorResult("failed to retry task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID),
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),
})
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)
+69 -144
View File
@@ -4,19 +4,13 @@ import (
"context"
"fmt"
"log/slog"
"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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -28,34 +22,23 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Claimed = true })
ownerKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, 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,
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusInProgress,
"agent", ownerKey,
WithOwner(ownerKey, t.manager.AgentDisplayName(ctx, ownerKey)),
WithContextInfo(ctx),
))
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 {
// Note: reviewer role exists but is not yet active in UI.
// All approval flows through leader. When reviewer role is enabled,
// restrict teammate agents from completing tasks directly.
// Note: reviewer role exists — when task has a reviewer, executeComplete
// routes to in_review status instead of completed. Leader still approves.
// Member agents always call this; leader bypasses via direct status update.
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -109,21 +92,14 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
taskNumber = completedTask.TaskNumber
taskSubject = completedTask.Subject
}
t.manager.BroadcastTeamEvent(ctx, 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,
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusCompleted,
"agent", ownerKey,
WithTaskInfo(taskNumber, taskSubject),
WithOwner(ownerKey, t.manager.AgentDisplayName(ctx, ownerKey)),
WithContextInfo(ctx),
))
// 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
@@ -135,7 +111,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. Cancellation goes through leader only.
team, agentID, err := t.manager.ResolveTeam(ctx)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -143,11 +119,6 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
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"
@@ -158,18 +129,13 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
return ErrorResult("failed to cancel task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, 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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusCancelled,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithReason(reason),
WithContextInfo(ctx),
))
// Dependent tasks are dispatched by the consumer after this agent's turn ends (post-turn).
@@ -177,12 +143,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
}
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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -206,19 +167,13 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Reviewed = true })
ownerKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, 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,
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusInReview,
"agent", ownerKey,
WithOwner(ownerKey, t.manager.AgentDisplayName(ctx, ownerKey)),
WithContextInfo(ctx),
))
return NewResult(fmt.Sprintf("Task %s submitted for review.", taskID))
}
@@ -226,7 +181,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. All approvals flow through leader or dashboard.
team, agentID, err := t.manager.ResolveTeam(ctx)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -240,11 +195,6 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
}
}
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.Store().GetTask(ctx, taskID)
if err != nil {
@@ -266,18 +216,13 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
newStatus = approved.Status
}
t.manager.BroadcastTeamEvent(ctx, 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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
newStatus,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithSubject(task.Subject),
WithContextInfo(ctx),
))
// Record approval as a task comment for audit trail.
approveMsg := fmt.Sprintf("Task approved (status: %s).", newStatus)
@@ -293,7 +238,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
// Note: reviewer role not yet active. Rejections flow through leader or dashboard.
team, agentID, err := t.manager.ResolveTeam(ctx)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -306,11 +251,6 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
}
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
reason, _ := args["text"].(string)
if reason == "" {
reason = "Rejected by user"
@@ -344,38 +284,28 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
if err := t.manager.Store().RejectTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to reject task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusCancelled,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithSubject(task.Subject),
WithReason(reason),
WithContextInfo(ctx),
))
return NewResult(fmt.Sprintf("Task %s rejected (cancelled). Use retry to re-dispatch manually.", taskID))
}
if err := t.manager.Store().AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
slog.Warn("reject: assign task failed", "task_id", taskID, "error", err)
return NewResult(fmt.Sprintf("Task %s rejected but could not assign. Use retry to re-dispatch manually.", taskID))
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusInProgress,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithSubject(task.Subject),
WithReason(reason),
WithContextInfo(ctx),
))
t.manager.DispatchTaskToAgent(ctx, task, team, *task.OwnerAgentID)
return NewResult(fmt.Sprintf("Task %s rejected and re-dispatched to %s with feedback.",
taskID, t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)))
@@ -385,18 +315,13 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
if err := t.manager.Store().RejectTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to reject task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusCancelled,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithSubject(task.Subject),
WithReason(reason),
WithContextInfo(ctx),
))
return NewResult(fmt.Sprintf("Task %s rejected.", taskID))
}
+44 -385
View File
@@ -4,335 +4,30 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
// resolveTeamAndTask is the common preamble for task mutation actions:
// resolve team context + parse task ID from args.
func (t *TeamTasksTool) resolveTeamAndTask(ctx context.Context, args map[string]any) (*store.TeamData, uuid.UUID, uuid.UUID, error) {
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
return nil, uuid.Nil, uuid.Nil, err
}
// Determine if caller is a lead or a member.
isLead := agentID == team.LeadAgentID
channel := ToolChannelFromCtx(ctx)
if channel == ChannelTeammate || channel == ChannelSystem {
isLead = true // system/teammate channels act on behalf of the lead
}
taskType, _ := args["task_type"].(string)
if taskType == "" {
taskType = "general"
}
if !isLead {
// Members may only create "request" tasks when the feature is enabled.
memberCfg := ParseMemberRequestConfig(team.Settings)
if !memberCfg.Enabled {
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
}
if taskType != "request" {
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
}
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
}
subject, _ := args["subject"].(string)
if subject == "" {
return ErrorResult("subject is required for create action")
}
description, _ := args["description"].(string)
priority := 0
if p, ok := args["priority"].(float64); ok {
priority = int(p)
}
var blockedBy []uuid.UUID
if raw, ok := args["blocked_by"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
}
blockedBy = append(blockedBy, id)
}
}
}
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.Store().GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
if depTask.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
}
switch depTask.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Do not block on finished tasks — create this task without blocked_by, "+
"or pass the completed task's result in the description instead.",
depID, depTask.Subject, depTask.Status))
}
}
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
assigneeKey, _ := args["assignee"].(string)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
return nil, uuid.Nil, uuid.Nil, err
}
// Verify assignee is a member of this team.
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == assigneeID {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
}
// Prevent lead from self-assigning — causes dual-session execution + loop.
// buildCreateHint already hides the lead from the member list hint,
// but weaker models may still attempt self-assignment.
if assigneeID == team.LeadAgentID {
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
}
requireApproval, _ := args["require_approval"].(bool)
status := store.TeamTaskStatusPending
if requireApproval {
status = store.TeamTaskStatusInReview
} else if len(blockedBy) > 0 {
status = store.TeamTaskStatusBlocked
}
// Assigned tasks without blockers stay pending — dispatched after the turn
// ends via post-turn processing (avoids race with blocked_by setup).
// Member requests without auto_dispatch stay pending for leader review.
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
chatID := ToolChatIDFromCtx(ctx)
// Compute team workspace via layered pipeline: tenant → team → user/chat.
shared := IsSharedWorkspace(team.Settings)
taskMeta := make(map[string]any)
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
TeamLayer(team.ID),
UserChatLayer(chatID, shared),
)
taskMeta["team_workspace"] = teamWsDir
// Auto-collect media files from current run to team workspace.
// When leader received files from user and creates a task, copy those
// files to the team workspace so members can access them via read_file.
// Also rewrite any media paths in the description to point to the workspace copy,
// since members can't access the original .media/ paths outside their workspace.
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
if wsDir, _ := taskMeta["team_workspace"].(string); wsDir != "" {
nameMap := RunMediaNamesFromCtx(ctx)
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
// Store as []any so type assertion works both before and after JSON round-trip.
files := make([]any, len(copiedPaths))
for i, p := range copiedPaths {
files[i] = p
}
taskMeta["attached_files"] = files
// Rewrite media paths in description so members see workspace paths.
for i, src := range mediaPaths {
if i < len(copiedPaths) {
description = strings.ReplaceAll(description, src, copiedPaths[i])
}
}
}
}
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
for i, id := range blockedBy {
ids[i] = id.String()
}
taskMeta["original_blocked_by"] = ids
}
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
taskMeta["peer_kind"] = pk
}
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
taskMeta["local_key"] = lk
}
// Store origin session key so deferred dispatches route announces correctly.
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
taskMeta["origin_session_key"] = sk
}
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
taskMeta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
taskMeta["origin_root_span_id"] = rootSpanID.String()
}
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: status,
BlockedBy: blockedBy,
Priority: priority,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: taskType,
CreatedByAgentID: &agentID,
ChatID: chatID,
Metadata: taskMeta,
}
task.OwnerAgentID = &assigneeID
// Auto-link member request to the member's current task as parent.
if !isLead && taskType == "request" {
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
task.ParentID = &parentUUID
}
}
}
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
// Persist media files copied during task creation as DB attachments,
// so the UI and queries see them in team_task_attachments table.
// (Members get auto-attached via WorkspaceInterceptor, but leaders
// don't run inside a task context — handle explicitly here.)
if files, ok := taskMeta["attached_files"].([]any); ok {
for _, f := range files {
filePath, ok := f.(string)
if !ok || filePath == "" {
continue
}
var fileSize int64
if info, err := os.Stat(filePath); err == nil {
fileSize = info.Size()
}
att := &store.TeamTaskAttachmentData{
TaskID: task.ID,
TeamID: team.ID,
ChatID: chatID,
Path: filePath,
FileSize: fileSize,
MimeType: mimeFromExt(filepath.Ext(filePath)),
CreatedByAgentID: &agentID,
}
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
}
}
}
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: status,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: agentKey,
})
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.AgentKeyFromID(ctx, assigneeID),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "fallback_dispatch",
})
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
}
}
}
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
if assigneeName == "" {
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
}
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
// Soft guardrail: warn if subject suggests multiple deliverables.
// Only checks subject (not description) — detailed descriptions are fine.
if subject != "" {
subjLower := strings.ToLower(subject)
hasCompound := strings.Contains(subjLower, " and ") &&
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
if hasCompound {
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
}
}
return NewResult(msg)
return team, agentID, taskID, nil
}
func (t *TeamTasksTool) executeComment(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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -368,19 +63,14 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return ErrorResult("failed to add comment: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
CommentText: truncatePreview(text, 500),
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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
"",
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithTaskInfo(task.TaskNumber, task.Subject),
WithCommentText(truncatePreview(text, 500)),
WithContextInfo(ctx),
))
// Record action flag after successful store operation.
recordTaskAction(ctx, func(f *TaskActionFlags) {
@@ -410,14 +100,8 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return NewResult(msg)
}
func (t *TeamTasksTool) executeProgress(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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -444,7 +128,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
}
// Early exit: task already terminal — skip DB write entirely.
// Reuses the task fetched above (line 391) so no extra query needed.
// Reuses the task fetched above so no extra query needed.
switch task.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled:
return SilentResult(fmt.Sprintf("Task already %s — progress update skipped.", task.Status))
@@ -456,7 +140,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
}
if err := t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
// Status may have changed between GetTask and UpdateTaskProgress.
// Status may have changed between GetTask and UpdateTaskProgress (race with completeTask).
// Fast path: check in-memory turn flags before hitting DB again.
if flags := TaskActionFlagsFromCtx(ctx); flags != nil && flags.Completed {
return SilentResult("Task already completed — progress update skipped.")
@@ -486,31 +170,21 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
if task.OwnerAgentID != nil {
ownerKey = t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: ownerKey,
ProgressPercent: percent,
ProgressStep: step,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
store.TeamTaskStatusInProgress,
"", "",
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(ownerKey),
WithProgress(percent, step),
WithContextInfo(ctx),
))
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
}
func (t *TeamTasksTool) executeAttach(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)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -548,24 +222,19 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
return ErrorResult("failed to attach file: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
"",
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithTaskInfo(task.TaskNumber, task.Subject),
WithContextInfo(ctx),
))
return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
}
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
@@ -573,11 +242,6 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify task belongs to this team (prevent cross-team update).
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
@@ -642,18 +306,13 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
return ErrorResult("failed to update task: " + err.Error())
}
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: task.Status,
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),
})
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, BuildTaskEventPayload(
team.ID.String(), taskID.String(),
task.Status,
"agent", t.manager.AgentKeyFromID(ctx, agentID),
WithSubject(task.Subject),
WithContextInfo(ctx),
))
return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID))
}
+1 -1
View File
@@ -297,6 +297,6 @@ func taskTeamWorkspace(task *store.TeamTaskData) string {
if task.Metadata == nil {
return ""
}
ws, _ := task.Metadata["team_workspace"].(string)
ws, _ := task.Metadata[TaskMetaTeamWorkspace].(string)
return ws
}
+29 -34
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
@@ -146,7 +145,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
// Resolve peer kind from context; fallback to task metadata, then "direct".
originPeerKind := ToolPeerKindFromCtx(ctx)
if originPeerKind == "" {
if pk, ok := task.Metadata["peer_kind"].(string); ok && pk != "" {
if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok && pk != "" {
originPeerKind = pk
} else {
originPeerKind = "direct"
@@ -154,50 +153,50 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
}
meta := map[string]string{
"origin_channel": originChannel,
"origin_peer_kind": originPeerKind,
"origin_chat_id": originChatID,
"origin_user_id": originUserID,
"from_agent": fromAgent,
"to_agent": ag.AgentKey,
"to_agent_display": ag.DisplayName,
"team_task_id": task.ID.String(),
"team_id": teamID.String(),
MetaOriginChannel: originChannel,
MetaOriginPeerKind: originPeerKind,
MetaOriginChatID: originChatID,
MetaOriginUserID: originUserID,
MetaFromAgent: fromAgent,
MetaToAgent: ag.AgentKey,
MetaToAgentDisplay: ag.DisplayName,
MetaTeamTaskID: task.ID.String(),
MetaTeamID: teamID.String(),
}
// Resolve local key from context; fallback to task metadata for deferred dispatches.
localKey := ToolLocalKeyFromCtx(ctx)
if localKey == "" {
if lk, ok := task.Metadata["local_key"].(string); ok {
if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok {
localKey = lk
}
}
if localKey != "" {
meta["origin_local_key"] = localKey
meta[MetaOriginLocalKey] = localKey
}
// Resolve origin session key from context; fallback to task metadata for deferred dispatches.
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
originSessionKey := ToolSessionKeyFromCtx(ctx)
if originSessionKey == "" {
if sk, ok := task.Metadata["origin_session_key"].(string); ok {
if sk, ok := task.Metadata[TaskMetaOriginSession].(string); ok {
originSessionKey = sk
}
}
if originSessionKey != "" {
meta["origin_session_key"] = originSessionKey
meta[MetaOriginSessionKey] = originSessionKey
}
// Pass leader agent ID so member agents can fallback-read leader's memory.
meta["leader_agent_id"] = team.LeadAgentID.String()
meta[MetaLeaderAgentID] = team.LeadAgentID.String()
// Pass the team workspace dir so member agents write files to the shared folder.
if ws := taskTeamWorkspace(task); ws != "" {
meta["team_workspace"] = ws
meta[MetaTeamWorkspace] = ws
}
// Propagate trace context so member agent's trace links back to the lead's trace,
// and the announce-back run nests under the lead's root span.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
meta["origin_trace_id"] = traceID.String()
meta[MetaOriginTraceID] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
meta["origin_root_span_id"] = rootSpanID.String()
meta[MetaOriginRootSpanID] = rootSpanID.String()
}
if !m.msgBus.TryPublishInbound(bus.InboundMessage{
@@ -300,12 +299,12 @@ func (m *TeamToolManager) restoreTraceContext(ctx context.Context, task *store.T
if task.Metadata == nil {
return ctx
}
if traceIDStr, ok := task.Metadata["origin_trace_id"].(string); ok {
if traceIDStr, ok := task.Metadata[TaskMetaOriginTrace].(string); ok {
if traceID, err := uuid.Parse(traceIDStr); err == nil {
ctx = tracing.WithTraceID(ctx, traceID)
}
}
if spanIDStr, ok := task.Metadata["origin_root_span_id"].(string); ok {
if spanIDStr, ok := task.Metadata[TaskMetaOriginRootSpan].(string); ok {
if spanID, err := uuid.Parse(spanIDStr); err == nil {
ctx = tracing.WithParentSpanID(ctx, spanID)
}
@@ -360,19 +359,15 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui
continue
}
dispatched[ownerID] = true
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, ownerID),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "dispatch_unblocked",
})
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
teamID.String(), task.ID.String(),
store.TeamTaskStatusInProgress,
"system", "dispatch_unblocked",
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(m.agentKeyFromID(ctx, ownerID)),
WithChannel(task.Channel),
WithChatID(task.ChatID),
))
// Append completed blocker results so the member agent has context.
if summary := m.buildBlockerResultsSummary(ctx, task); summary != "" {
+8 -10
View File
@@ -183,16 +183,14 @@ func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.
return ErrorResult("failed to create escalation task: " + err.Error())
}
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: store.TeamTaskStatusPending,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
})
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
team.ID.String(), task.ID.String(),
store.TeamTaskStatusPending,
"", "",
WithSubject(subject),
WithContextInfo(ctx),
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
))
// Notify channel if possible.
m.notifyChannelReview(task)
+19 -30
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -59,14 +58,11 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U
if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil {
slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err)
}
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, BuildTaskEventPayload(
teamID.String(), taskID.String(),
store.TeamTaskStatusFailed,
"system", "post_turn",
))
slog.Warn("post_turn: task failed — invalid blocked_by",
"task_id", taskID, "identifier", task.Identifier, "bad_ref", badRef)
}
@@ -102,19 +98,15 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U
slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
teamID.String(), task.ID.String(),
store.TeamTaskStatusInProgress,
"system", "post_turn",
WithTaskInfo(task.TaskNumber, task.Subject),
WithOwnerAgentKey(m.agentKeyFromID(ctx, *task.OwnerAgentID)),
WithChannel(task.Channel),
WithChatID(task.ChatID),
))
// Restore leader's trace context from task metadata (ctx here is the
// consumer goroutine context which has no trace after the turn ends).
dispatchCtx := m.restoreTraceContext(ctx, task)
@@ -144,14 +136,11 @@ func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID,
if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil {
slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err)
}
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: id.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, BuildTaskEventPayload(
teamID.String(), id.String(),
store.TeamTaskStatusFailed,
"system", "post_turn",
))
}
// Notify leader via system message.