diff --git a/cmd/gateway.go b/cmd/gateway.go index e1b35512..8cceba25 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 48563014..0690bf6b 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -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 } diff --git a/cmd/gateway_consumer_deps.go b/cmd/gateway_consumer_deps.go new file mode 100644 index 00000000..76a13c6f --- /dev/null +++ b/cmd/gateway_consumer_deps.go @@ -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 +} diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 315daa30..3b28e09d 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -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 } diff --git a/cmd/gateway_consumer_helpers.go b/cmd/gateway_consumer_helpers.go index 565d5636..da9158b2 100644 --- a/cmd/gateway_consumer_helpers.go +++ b/cmd/gateway_consumer_helpers.go @@ -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 } diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 2a4a5ebf..b9ced614 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -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) } diff --git a/cmd/gateway_consumer_post_turn.go b/cmd/gateway_consumer_post_turn.go new file mode 100644 index 00000000..33bd24f9 --- /dev/null +++ b/cmd/gateway_consumer_post_turn.go @@ -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 +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 5a563493..785a9841 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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 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 tags so LLM can reference them. - l.enrichVideoIDs(messages, mediaRefs) - } - - // 2e. Enrich 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 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 { diff --git a/internal/agent/loop_input_media.go b/internal/agent/loop_input_media.go new file mode 100644 index 00000000..75b5d56c --- /dev/null +++ b/internal/agent/loop_input_media.go @@ -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 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 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 +} diff --git a/internal/agent/loop_team_reminders.go b/internal/agent/loop_team_reminders.go new file mode 100644 index 00000000..3138882c --- /dev/null +++ b/internal/agent/loop_team_reminders.go @@ -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 +} diff --git a/internal/agent/loop_tool_filter.go b/internal/agent/loop_tool_filter.go new file mode 100644 index 00000000..8304fb4b --- /dev/null +++ b/internal/agent/loop_tool_filter.go @@ -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 +} diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 30ff8397..550f20d6 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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 diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 606dd496..03a658f5 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -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) } } diff --git a/internal/tasks/task_ticker.go b/internal/tasks/task_ticker.go index d9010e12..1c086ddb 100644 --- a/internal/tasks/task_ticker.go +++ b/internal/tasks/task_ticker.go @@ -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", + )) } } diff --git a/internal/tools/executor.go b/internal/tools/executor.go new file mode 100644 index 00000000..0dc72826 --- /dev/null +++ b/internal/tools/executor.go @@ -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) diff --git a/internal/tools/policy.go b/internal/tools/policy.go index 7432962d..e7f70a0e 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -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, diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go index 74316884..dbc8c6f9 100644 --- a/internal/tools/subagent_exec.go +++ b/internal/tools/subagent_exec.go @@ -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", diff --git a/internal/tools/team_event_helpers.go b/internal/tools/team_event_helpers.go new file mode 100644 index 00000000..b277da29 --- /dev/null +++ b/internal/tools/team_event_helpers.go @@ -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 } +} diff --git a/internal/tools/team_metadata_keys.go b/internal/tools/team_metadata_keys.go new file mode 100644 index 00000000..c12f5006 --- /dev/null +++ b/internal/tools/team_metadata_keys.go @@ -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" +) diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go index cc28b412..2b11b2bb 100644 --- a/internal/tools/team_tasks_blocker.go +++ b/internal/tools/team_tasks_blocker.go @@ -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) diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go new file mode 100644 index 00000000..00b8bbe3 --- /dev/null +++ b/internal/tools/team_tasks_create.go @@ -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=\"\") 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) +} diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go index f462344d..b2d163cb 100644 --- a/internal/tools/team_tasks_followup.go +++ b/internal/tools/team_tasks_followup.go @@ -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) diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 6c5fbc7c..b271c1a1 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -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)) } diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 2481746c..bc037b07 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -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=\"\") 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)) } diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index 00d8ef90..8e3be1f9 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -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 } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index 7f12d3fc..894ca50c 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -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 != "" { diff --git a/internal/tools/team_tool_helpers.go b/internal/tools/team_tool_helpers.go index 7322e7d8..8d594c00 100644 --- a/internal/tools/team_tool_helpers.go +++ b/internal/tools/team_tool_helpers.go @@ -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) diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index b9b35127..f0c459bc 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -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.