mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
refactor: decompose consumer, agent loop, team tools, and MCP manager
6-phase pure refactor — zero behavior change, all tests pass. Phase 1: DRY event payloads + metadata keys - BuildTaskEventPayload() helper with functional options replaces 24 inline constructions - 26 metadata string constants replace ~66 magic strings across dispatch/consumer Phase 2: Consumer handler decomposition - ConsumerDeps struct collapses 11-13 positional params to 3 per handler - Extract startTaskLockRenewal() and resolveTeamTaskOutcome() Phase 3: loop.go extraction (978→686 LOC) - enrichInputMedia(): media persistence, ref collection, tag enrichment - injectTeamTaskReminders(): leader/member task context injection - buildFilteredTools(): policy, bootstrap, channel, iteration filtering - collectRefsByKind() helper eliminates 3x copy-paste Phase 4: team_tasks_mutations split (630→318 LOC) - executeCreate extracted to team_tasks_create.go - resolveTeamAndTask() helper used by 13 execute* functions Phase 5: MCP LoadForAgent split (~90→~15 LOC body) - resolveServerCredentials() + connectAndFilter() helpers Phase 6: Comments + DI - 5 inaccurate comments fixed - ToolExecutor interface for dependency inversion (Loop.tools field) - FilterTools accepts ToolExecutor instead of concrete *Registry
This commit is contained in:
1 parent
4b6ed8cfce
commit
78253f1841
28 files changed
+1591
-1392
No files matched your search
+8
-8
@@ -177,18 +177,18 @@ func runGateway() {
|
||||
label = fmt.Sprintf("%d tasks", len(items))
|
||||
}
|
||||
batchMeta := map[string]string{
|
||||
"origin_channel": meta.OriginChannel,
|
||||
"origin_peer_kind": meta.OriginPeerKind,
|
||||
"parent_agent": meta.ParentAgent,
|
||||
"subagent_label": label,
|
||||
"origin_trace_id": meta.OriginTraceID,
|
||||
"origin_root_span_id": meta.OriginRootSpanID,
|
||||
tools.MetaOriginChannel: meta.OriginChannel,
|
||||
tools.MetaOriginPeerKind: meta.OriginPeerKind,
|
||||
tools.MetaParentAgent: meta.ParentAgent,
|
||||
tools.MetaSubagentLabel: label,
|
||||
tools.MetaOriginTraceID: meta.OriginTraceID,
|
||||
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
|
||||
}
|
||||
if meta.OriginLocalKey != "" {
|
||||
batchMeta["origin_local_key"] = meta.OriginLocalKey
|
||||
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
|
||||
}
|
||||
if meta.OriginSessionKey != "" {
|
||||
batchMeta["origin_session_key"] = meta.OriginSessionKey
|
||||
batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey
|
||||
}
|
||||
// Collect media from all items in the batch.
|
||||
var batchMedia []bus.MediaFile
|
||||
|
||||
+25
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// startTaskLockRenewal extends the task lock every 5 min while the agent works.
|
||||
// Returns a stop func — call it when the run finishes. Returns nil if taskID is
|
||||
// invalid or teamStore is nil.
|
||||
func startTaskLockRenewal(ctx context.Context, teamStore store.TeamStore, taskID, teamID uuid.UUID) (stop func()) {
|
||||
if taskID == uuid.Nil || teamStore == nil {
|
||||
return nil
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := teamStore.RenewTaskLock(ctx, taskID, teamID); err != nil {
|
||||
slog.Warn("teammate lock renewal failed", "task_id", taskID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("teammate lock renewed", "task_id", taskID)
|
||||
case <-ch:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(ch) }
|
||||
}
|
||||
|
||||
// teammateTaskMeta holds resolved task context for post-turn processing.
|
||||
type teammateTaskMeta struct {
|
||||
TaskID uuid.UUID
|
||||
TeamID uuid.UUID
|
||||
ToAgent string
|
||||
Channel string
|
||||
ChatID string
|
||||
Subject string
|
||||
TaskNumber int
|
||||
}
|
||||
|
||||
// resolveTeamTaskOutcome determines the correct task lifecycle action
|
||||
// after a teammate run: error→fail, completed/escalated→skip, reviewed→renew,
|
||||
// loopKilled→fail, default→auto-complete.
|
||||
// Returns the resolved team (for announce routing) and dispatches unblocked tasks.
|
||||
func resolveTeamTaskOutcome(
|
||||
ctx context.Context,
|
||||
deps *ConsumerDeps,
|
||||
outcome scheduler.RunOutcome,
|
||||
flags *tools.TaskActionFlags,
|
||||
meta teammateTaskMeta,
|
||||
) *store.TeamData {
|
||||
if meta.TaskID == uuid.Nil || deps.TeamStore == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cachedTeam, _ := deps.TeamStore.GetTeam(ctx, meta.TeamID)
|
||||
if cachedTeam == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check current task status — agent may have already updated it via tool.
|
||||
currentTask, taskErr := deps.TeamStore.GetTask(ctx, meta.TaskID)
|
||||
alreadyTerminal := taskErr == nil && currentTask != nil &&
|
||||
(currentTask.Status == store.TeamTaskStatusCompleted ||
|
||||
currentTask.Status == store.TeamTaskStatusFailed ||
|
||||
currentTask.Status == store.TeamTaskStatusCancelled)
|
||||
|
||||
if alreadyTerminal {
|
||||
// Always dispatch unblocked tasks even if already terminal.
|
||||
if deps.PostTurn != nil {
|
||||
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
|
||||
}
|
||||
return cachedTeam
|
||||
}
|
||||
|
||||
toAgent := meta.ToAgent
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
taskSubject := meta.Subject
|
||||
taskNumber := meta.TaskNumber
|
||||
taskChannel := meta.Channel
|
||||
taskChatID := meta.ChatID
|
||||
|
||||
// Enrich with live task data if available.
|
||||
if currentTask != nil {
|
||||
if currentTask.Subject != "" {
|
||||
taskSubject = currentTask.Subject
|
||||
}
|
||||
if currentTask.TaskNumber != 0 {
|
||||
taskNumber = currentTask.TaskNumber
|
||||
}
|
||||
if currentTask.Channel != "" {
|
||||
taskChannel = currentTask.Channel
|
||||
}
|
||||
if currentTask.ChatID != "" {
|
||||
taskChatID = currentTask.ChatID
|
||||
}
|
||||
}
|
||||
|
||||
// Smart post-turn decision based on action flags.
|
||||
// Only error, completed/escalated, and reviewed block auto-complete.
|
||||
switch {
|
||||
case outcome.Err != nil:
|
||||
// Agent errored → auto-fail.
|
||||
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, outcome.Err.Error()); err != nil {
|
||||
slog.Warn("auto-complete: FailTask error", "task_id", meta.TaskID, "error", err)
|
||||
} else {
|
||||
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
||||
meta.TeamID.String(), meta.TaskID.String(),
|
||||
store.TeamTaskStatusFailed,
|
||||
"agent", toAgent,
|
||||
tools.WithTaskInfo(taskNumber, taskSubject),
|
||||
tools.WithReason(outcome.Err.Error()),
|
||||
tools.WithChannel(taskChannel),
|
||||
tools.WithChatID(taskChatID),
|
||||
tools.WithTimestamp(now),
|
||||
))
|
||||
}
|
||||
|
||||
case flags.Completed || flags.Escalated:
|
||||
// Tool already completed/failed the task — skip auto-complete.
|
||||
slog.Info("post-turn: tool handled task", "task_id", meta.TaskID,
|
||||
"completed", flags.Completed, "escalated", flags.Escalated)
|
||||
|
||||
case flags.Reviewed:
|
||||
// Task submitted for review — skip auto-complete, renew lock.
|
||||
_ = deps.TeamStore.RenewTaskLock(ctx, meta.TaskID, meta.TeamID)
|
||||
slog.Info("post-turn: task submitted for review", "task_id", meta.TaskID)
|
||||
|
||||
case outcome.Result != nil && outcome.Result.LoopKilled:
|
||||
// Loop detector killed the run → auto-fail instead of auto-complete.
|
||||
// The agent was terminated (not stuck in_progress) — mark as failed
|
||||
// so the leader sees clear failure signal and can retry directly.
|
||||
failMsg := outcome.Result.Content
|
||||
if failMsg == "" {
|
||||
failMsg = "Agent run terminated by loop detector"
|
||||
}
|
||||
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, failMsg); err != nil {
|
||||
slog.Warn("auto-fail: FailTask error (loop kill)", "task_id", meta.TaskID, "error", err)
|
||||
} else {
|
||||
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
||||
meta.TeamID.String(), meta.TaskID.String(),
|
||||
store.TeamTaskStatusFailed,
|
||||
"system", "loop_detector",
|
||||
tools.WithTaskInfo(taskNumber, taskSubject),
|
||||
tools.WithReason("loop_detector_kill"),
|
||||
tools.WithChannel(taskChannel),
|
||||
tools.WithChatID(taskChatID),
|
||||
tools.WithTimestamp(now),
|
||||
))
|
||||
}
|
||||
slog.Warn("post-turn: loop detector killed member run",
|
||||
"task_id", meta.TaskID, "agent", toAgent)
|
||||
|
||||
default:
|
||||
// Agent turn ended without terminal action — auto-complete.
|
||||
// Covers: Progressed, Commented, Claimed, or no flags at all.
|
||||
if outcome.Result != nil {
|
||||
result := outcome.Result.Content
|
||||
if len(outcome.Result.Deliverables) > 0 {
|
||||
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
|
||||
}
|
||||
if len(result) > 100_000 {
|
||||
result = result[:100_000] + "\n[truncated]"
|
||||
}
|
||||
if err := deps.TeamStore.CompleteTask(ctx, meta.TaskID, meta.TeamID, result); err != nil {
|
||||
slog.Warn("auto-complete: CompleteTask error", "task_id", meta.TaskID, "error", err)
|
||||
} else {
|
||||
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
||||
meta.TeamID.String(), meta.TaskID.String(),
|
||||
store.TeamTaskStatusCompleted,
|
||||
"agent", toAgent,
|
||||
tools.WithTaskInfo(taskNumber, taskSubject),
|
||||
tools.WithOwnerAgentKey(toAgent),
|
||||
tools.WithChannel(taskChannel),
|
||||
tools.WithChatID(taskChatID),
|
||||
tools.WithTimestamp(now),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always dispatch unblocked tasks after member turn ends,
|
||||
// regardless of whether the task was already completed by the tool.
|
||||
// This ensures dependent tasks start only after the member's run finishes.
|
||||
if deps.PostTurn != nil {
|
||||
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
|
||||
}
|
||||
|
||||
return cachedTeam
|
||||
}
|
||||
+12
-305
@@ -5,15 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
@@ -67,230 +64,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
|
||||
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.ChatTitle, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter, req.LightContext)
|
||||
|
||||
// 1b. Determine image routing strategy.
|
||||
// If read_image tool has a dedicated vision provider, images are NOT attached inline
|
||||
// to the main LLM — the agent calls read_image tool instead. This avoids sending
|
||||
// images to providers that don't support vision or have strict content filters.
|
||||
deferToReadImageTool := l.hasReadImageProvider()
|
||||
// 1b–2f. Persist and enrich all incoming media (images, docs, audio, video).
|
||||
ctx, messages, mediaRefs := l.enrichInputMedia(ctx, &req, messages)
|
||||
|
||||
if !deferToReadImageTool {
|
||||
// Inline mode: reload historical images directly into messages for main provider.
|
||||
l.reloadMediaForMessages(messages, maxMediaReloadMessages)
|
||||
}
|
||||
|
||||
// 2. Process media: sanitize images, persist to media store.
|
||||
var mediaRefs []providers.MediaRef
|
||||
if len(req.Media) > 0 {
|
||||
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
|
||||
// Load current-turn images from persisted refs (Path is always set for new uploads).
|
||||
var imageFiles []bus.MediaFile
|
||||
for _, ref := range mediaRefs {
|
||||
if ref.Kind == "image" && ref.Path != "" {
|
||||
imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
|
||||
}
|
||||
}
|
||||
if deferToReadImageTool {
|
||||
// File-ref mode: images primarily accessed via read_image(path=...).
|
||||
// Still load into context as fallback — if LLM omits the path param,
|
||||
// read_image can fall back to context images. This costs Go memory
|
||||
// but NOT LLM tokens (base64 is in Go context, not sent to provider).
|
||||
if images := loadImages(imageFiles); len(images) > 0 {
|
||||
ctx = tools.WithMediaImages(ctx, images)
|
||||
}
|
||||
slog.Info("vision: file-ref mode, images accessible via read_image tool",
|
||||
"count", len(imageFiles), "agent", l.id)
|
||||
} else if images := loadImages(imageFiles); len(images) > 0 {
|
||||
// Inline mode: read files, base64 encode, attach to message + context.
|
||||
messages[len(messages)-1].Images = images
|
||||
ctx = tools.WithMediaImages(ctx, images)
|
||||
slog.Info("vision: attached images inline to main provider", "count", len(images), "agent", l.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2a. Load historical images into context for read_image tool.
|
||||
// Both modes need this: inline mode for main LLM, file-ref mode as fallback
|
||||
// when LLM calls read_image without the path param.
|
||||
if l.mediaStore != nil {
|
||||
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
|
||||
}
|
||||
|
||||
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
|
||||
// Historical first, current last — so refs[len-1] is always the most recent file.
|
||||
var docRefs []providers.MediaRef
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for _, ref := range messages[i].MediaRefs {
|
||||
if ref.Kind == "document" {
|
||||
docRefs = append(docRefs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range mediaRefs {
|
||||
if ref.Kind == "document" {
|
||||
docRefs = append(docRefs, ref)
|
||||
}
|
||||
}
|
||||
if len(docRefs) > 0 {
|
||||
ctx = tools.WithMediaDocRefs(ctx, docRefs)
|
||||
// Enrich the last user message with persisted file paths so skills can access
|
||||
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
|
||||
l.enrichDocumentPaths(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
|
||||
var audioRefs []providers.MediaRef
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for _, ref := range messages[i].MediaRefs {
|
||||
if ref.Kind == "audio" {
|
||||
audioRefs = append(audioRefs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range mediaRefs {
|
||||
if ref.Kind == "audio" {
|
||||
audioRefs = append(audioRefs, ref)
|
||||
}
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
|
||||
// Embed media IDs into <media:audio> tags so LLM can reference them.
|
||||
l.enrichAudioIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
|
||||
var videoRefs []providers.MediaRef
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for _, ref := range messages[i].MediaRefs {
|
||||
if ref.Kind == "video" {
|
||||
videoRefs = append(videoRefs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range mediaRefs {
|
||||
if ref.Kind == "video" {
|
||||
videoRefs = append(videoRefs, ref)
|
||||
}
|
||||
}
|
||||
if len(videoRefs) > 0 {
|
||||
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
|
||||
// Embed media IDs into <media:video> tags so LLM can reference them.
|
||||
l.enrichVideoIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
|
||||
// knows images were received and stored (consistent with audio/video enrichment).
|
||||
l.enrichImageIDs(messages, mediaRefs)
|
||||
|
||||
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
|
||||
// This enables read_image(path=...) for both current and historical images.
|
||||
if deferToReadImageTool {
|
||||
l.enrichImagePaths(messages)
|
||||
}
|
||||
|
||||
// 2f. Collect all media file paths for team workspace auto-collect.
|
||||
// When the leader calls team_tasks(create), these paths are copied to the
|
||||
// team workspace so members can access attached files.
|
||||
if len(mediaRefs) > 0 && l.mediaStore != nil {
|
||||
var mediaPaths []string
|
||||
for _, ref := range mediaRefs {
|
||||
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
|
||||
if ref.Path != "" {
|
||||
mediaPaths = append(mediaPaths, ref.Path)
|
||||
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
mediaPaths = append(mediaPaths, p)
|
||||
}
|
||||
}
|
||||
if len(mediaPaths) > 0 {
|
||||
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
|
||||
// Extract original filenames from <media:document name="X" path="Y"> tags
|
||||
// in the last user message (enriched in step 2b above).
|
||||
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
|
||||
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
|
||||
ctx = tools.WithRunMediaNames(ctx, nameMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks.
|
||||
// Stale recovery (expired lock → pending) is handled by the background TaskTicker.
|
||||
// Reminders are injected BEFORE the user message so the user's actual message is always
|
||||
// the last message — prevents trailing assistant messages that proxy providers reject.
|
||||
if l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
|
||||
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "active", req.UserID, "", "", 0, 0); err == nil {
|
||||
var stale []string
|
||||
var inProgress []string
|
||||
for _, t := range tasks {
|
||||
if t.Status == store.TeamTaskStatusPending {
|
||||
age := time.Since(t.CreatedAt).Truncate(time.Minute)
|
||||
stale = append(stale, fmt.Sprintf("- %s: \"%s\" (pending %s)", t.ID, t.Subject, age))
|
||||
}
|
||||
if t.Status == store.TeamTaskStatusInProgress {
|
||||
age := time.Since(t.UpdatedAt).Truncate(time.Minute)
|
||||
progressInfo := fmt.Sprintf("in progress %s", age)
|
||||
if t.ProgressPercent > 0 {
|
||||
if t.ProgressStep != "" {
|
||||
progressInfo = fmt.Sprintf("%d%% — %s, %s", t.ProgressPercent, t.ProgressStep, age)
|
||||
} else {
|
||||
progressInfo = fmt.Sprintf("%d%%, %s", t.ProgressPercent, age)
|
||||
}
|
||||
}
|
||||
inProgress = append(inProgress, fmt.Sprintf("- %s: \"%s\" (%s)", t.ID, t.Subject, progressInfo))
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if len(stale) > 0 {
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"You have %d pending team task(s) awaiting dispatch:\n%s\n"+
|
||||
"These tasks will be auto-dispatched to available team members. If no longer needed, cancel with team_tasks action=cancel.",
|
||||
len(stale), strings.Join(stale, "\n")))
|
||||
}
|
||||
if len(inProgress) > 0 {
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"You have %d in-progress team task(s) being handled by team members:\n%s\n"+
|
||||
"Their results will arrive automatically. Do NOT cancel, re-create, or re-spawn these tasks.",
|
||||
len(inProgress), strings.Join(inProgress, "\n")))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
reminder := "[System] " + strings.Join(parts, "\n\n")
|
||||
// Pop user message, inject reminder, push user message back
|
||||
userMsg := messages[len(messages)-1]
|
||||
messages = messages[:len(messages)-1]
|
||||
messages = append(messages,
|
||||
providers.Message{Role: "user", Content: reminder},
|
||||
providers.Message{Role: "assistant", Content: "I see the task status. Let me handle accordingly."},
|
||||
userMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2h. Member task reminder: inject task context for members working on dispatched tasks.
|
||||
// Caches task subject/number for mid-loop progress nudge (avoids extra DB query).
|
||||
var memberTaskSubject string
|
||||
var memberTaskNumber int
|
||||
if req.TeamTaskID != "" && l.teamStore != nil {
|
||||
if taskUUID, err := uuid.Parse(req.TeamTaskID); err == nil {
|
||||
if task, err := l.teamStore.GetTask(ctx, taskUUID); err == nil && task != nil {
|
||||
memberTaskSubject = task.Subject
|
||||
memberTaskNumber = task.TaskNumber
|
||||
reminder := fmt.Sprintf(
|
||||
"[System] You are working on team task #%d: %q. "+
|
||||
"Stay focused on this task. Your final response becomes the task result — make it clear and complete. "+
|
||||
"For long tasks, report progress: team_tasks(action=\"progress\", percent=50, text=\"status\").",
|
||||
task.TaskNumber, task.Subject)
|
||||
// Pop user message, inject reminder, push user message back
|
||||
userMsg := messages[len(messages)-1]
|
||||
messages = messages[:len(messages)-1]
|
||||
messages = append(messages,
|
||||
providers.Message{Role: "user", Content: reminder},
|
||||
providers.Message{Role: "assistant", Content: "Understood. I'll focus on this task and report progress."},
|
||||
userMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2g–2h. Inject leader pending-task reminders and member task context.
|
||||
messages, memberTask := l.injectTeamTaskReminders(ctx, &req, messages)
|
||||
|
||||
// 3. Buffer new messages — write to session only AFTER the run completes.
|
||||
// This prevents concurrent runs from seeing each other's in-progress messages.
|
||||
@@ -320,8 +98,6 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
pendingMsgs: initPendingMsgs,
|
||||
}
|
||||
|
||||
// Member progress nudge: remind dispatched members to report progress (every 6 iterations).
|
||||
|
||||
// Inject retry hook so channels can update placeholder on LLM retries.
|
||||
ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, err error) {
|
||||
emitRun(AgentEvent{
|
||||
@@ -381,7 +157,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
|
||||
// Member progress nudge: remind to report progress every 6 iterations.
|
||||
// Suggests percent based on iteration ratio — model can adjust but has a baseline.
|
||||
if req.TeamTaskID != "" && memberTaskSubject != "" && rs.iteration > 0 && rs.iteration%6 == 0 {
|
||||
if req.TeamTaskID != "" && memberTask.Subject != "" && rs.iteration > 0 && rs.iteration%6 == 0 {
|
||||
var nudge string
|
||||
if maxIter > 0 {
|
||||
suggestedPct := rs.iteration * 100 / maxIter
|
||||
@@ -389,13 +165,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
"[System] You are at iteration %d/%d (~%d%% of budget) working on task #%d: %q. "+
|
||||
"Report your progress now: team_tasks(action=\"progress\", percent=%d, text=\"what you've accomplished so far\"). "+
|
||||
"Adjust percent based on actual work completed.",
|
||||
rs.iteration, maxIter, suggestedPct, memberTaskNumber, memberTaskSubject, suggestedPct)
|
||||
rs.iteration, maxIter, suggestedPct, memberTask.TaskNumber, memberTask.Subject, suggestedPct)
|
||||
} else {
|
||||
nudge = fmt.Sprintf(
|
||||
"[System] You are at iteration %d working on task #%d: %q. "+
|
||||
"Report your progress now: team_tasks(action=\"progress\", percent=50, text=\"what you've accomplished so far\"). "+
|
||||
"Adjust percent based on actual work completed.",
|
||||
rs.iteration, memberTaskNumber, memberTaskSubject)
|
||||
rs.iteration, memberTask.TaskNumber, memberTask.Subject)
|
||||
}
|
||||
messages = append(messages, providers.Message{Role: "user", Content: nudge})
|
||||
}
|
||||
@@ -423,81 +199,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
Payload: map[string]any{"phase": "thinking", "iteration": rs.iteration},
|
||||
})
|
||||
|
||||
// Build provider request with policy-filtered tools
|
||||
// Build per-iteration tool list: policy, tenant exclusions, bootstrap, skill visibility,
|
||||
// channel type, and final-iteration stripping.
|
||||
var toolDefs []providers.ToolDefinition
|
||||
var allowedTools map[string]bool
|
||||
if l.toolPolicy != nil {
|
||||
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, req.ToolAllow, false, false)
|
||||
allowedTools = make(map[string]bool, len(toolDefs))
|
||||
for _, td := range toolDefs {
|
||||
allowedTools[td.Function.Name] = true
|
||||
}
|
||||
} else {
|
||||
toolDefs = l.tools.ProviderDefs()
|
||||
}
|
||||
|
||||
// Per-tenant tool exclusions: remove tools disabled for this agent's tenant.
|
||||
if len(l.disabledTools) > 0 {
|
||||
filtered := toolDefs[:0]
|
||||
for _, td := range toolDefs {
|
||||
if !l.disabledTools[td.Function.Name] {
|
||||
filtered = append(filtered, td)
|
||||
} else {
|
||||
delete(allowedTools, td.Function.Name)
|
||||
}
|
||||
}
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Bootstrap mode: restrict API tool definitions to write_file only (open agents).
|
||||
// Predefined agents keep all tools — BOOTSTRAP.md guides behavior.
|
||||
if hadBootstrap && l.agentType != store.AgentTypePredefined {
|
||||
var bootstrapDefs []providers.ToolDefinition
|
||||
for _, td := range toolDefs {
|
||||
if bootstrapToolAllowlist[td.Function.Name] {
|
||||
bootstrapDefs = append(bootstrapDefs, td)
|
||||
}
|
||||
}
|
||||
toolDefs = bootstrapDefs
|
||||
}
|
||||
|
||||
// Hide skill_manage from LLM when skill_evolve is off.
|
||||
// Tool stays in the registry (shared) but won't appear in API tool definitions.
|
||||
if !l.skillEvolve {
|
||||
filtered := toolDefs[:0:0]
|
||||
for _, td := range toolDefs {
|
||||
if td.Function.Name != "skill_manage" {
|
||||
filtered = append(filtered, td)
|
||||
}
|
||||
}
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Hide channel-specific tools when channel type doesn't match.
|
||||
if req.ChannelType != "" {
|
||||
filtered := toolDefs[:0:0]
|
||||
for _, td := range toolDefs {
|
||||
if tool, ok := l.tools.Get(td.Function.Name); ok {
|
||||
if ca, ok := tool.(tools.ChannelAware); ok {
|
||||
if !slices.Contains(ca.RequiredChannelTypes(), req.ChannelType) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, td)
|
||||
}
|
||||
toolDefs = filtered
|
||||
}
|
||||
|
||||
// Final iteration: strip all tools to force a text-only response.
|
||||
// Without this the model may keep requesting tools and exit with "...".
|
||||
if rs.iteration == maxIter {
|
||||
toolDefs = nil
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "user",
|
||||
Content: "[System] Final iteration reached. Summarize all findings and respond to the user now. No more tool calls allowed.",
|
||||
})
|
||||
}
|
||||
toolDefs, allowedTools, messages = l.buildFilteredTools(&req, hadBootstrap, rs.iteration, maxIter, messages)
|
||||
|
||||
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
|
||||
model := l.model
|
||||
@@ -649,7 +355,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
continue
|
||||
}
|
||||
|
||||
// No tool calls → done
|
||||
// No tool calls — exit or drain injected follow-ups.
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
// Mid-run injection (Point B): drain all buffered user follow-up messages
|
||||
// before exiting. If found, save current assistant response and continue
|
||||
@@ -808,6 +514,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
} else {
|
||||
// Multiple tools: parallel execution via goroutines.
|
||||
// Each goroutine performs lazy MCP activation + policy checking independently.
|
||||
// Tool instances are immutable (context-based) so concurrent access is safe.
|
||||
// Results are collected then processed sequentially for deterministic ordering.
|
||||
type indexedResult struct {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// collectRefsByKind gathers MediaRefs of a given kind from message history
|
||||
// (reverse order) and current-turn refs. Historical first, current last.
|
||||
func collectRefsByKind(messages []providers.Message, currentRefs []providers.MediaRef, kind string) []providers.MediaRef {
|
||||
var refs []providers.MediaRef
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for _, ref := range messages[i].MediaRefs {
|
||||
if ref.Kind == kind {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range currentRefs {
|
||||
if ref.Kind == kind {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// enrichInputMedia processes incoming media (images, documents, audio, video),
|
||||
// persists them, enriches messages with media tags, and populates context
|
||||
// with refs for tool access. Returns updated context, modified messages, and current-turn media refs.
|
||||
func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages []providers.Message) (context.Context, []providers.Message, []providers.MediaRef) {
|
||||
// 1b. Determine image routing strategy.
|
||||
// If read_image tool has a dedicated vision provider, images are NOT attached inline
|
||||
// to the main LLM — the agent calls read_image tool instead. This avoids sending
|
||||
// images to providers that don't support vision or have strict content filters.
|
||||
deferToReadImageTool := l.hasReadImageProvider()
|
||||
|
||||
if !deferToReadImageTool {
|
||||
// Inline mode: reload historical images directly into messages for main provider.
|
||||
l.reloadMediaForMessages(messages, maxMediaReloadMessages)
|
||||
}
|
||||
|
||||
// 2. Process media: sanitize images, persist to media store.
|
||||
var mediaRefs []providers.MediaRef
|
||||
if len(req.Media) > 0 {
|
||||
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
|
||||
// Load current-turn images from persisted refs (Path is always set for new uploads).
|
||||
var imageFiles []bus.MediaFile
|
||||
for _, ref := range mediaRefs {
|
||||
if ref.Kind == "image" && ref.Path != "" {
|
||||
imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
|
||||
}
|
||||
}
|
||||
if deferToReadImageTool {
|
||||
// File-ref mode: images primarily accessed via read_image(path=...).
|
||||
// Still load into context as fallback — if LLM omits the path param,
|
||||
// read_image can fall back to context images. This costs Go memory
|
||||
// but NOT LLM tokens (base64 is in Go context, not sent to provider).
|
||||
if images := loadImages(imageFiles); len(images) > 0 {
|
||||
ctx = tools.WithMediaImages(ctx, images)
|
||||
}
|
||||
slog.Info("vision: file-ref mode, images accessible via read_image tool",
|
||||
"count", len(imageFiles), "agent", l.id)
|
||||
} else if images := loadImages(imageFiles); len(images) > 0 {
|
||||
// Inline mode: read files, base64 encode, attach to message + context.
|
||||
messages[len(messages)-1].Images = images
|
||||
ctx = tools.WithMediaImages(ctx, images)
|
||||
slog.Info("vision: attached images inline to main provider", "count", len(images), "agent", l.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2a. Load historical images into context for read_image tool.
|
||||
// Both modes need this: inline mode for main LLM, file-ref mode as fallback
|
||||
// when LLM calls read_image without the path param.
|
||||
if l.mediaStore != nil {
|
||||
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
|
||||
}
|
||||
|
||||
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
|
||||
if docRefs := collectRefsByKind(messages, mediaRefs, "document"); len(docRefs) > 0 {
|
||||
ctx = tools.WithMediaDocRefs(ctx, docRefs)
|
||||
// Enrich the last user message with persisted file paths so skills can access
|
||||
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
|
||||
l.enrichDocumentPaths(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
|
||||
if audioRefs := collectRefsByKind(messages, mediaRefs, "audio"); len(audioRefs) > 0 {
|
||||
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
|
||||
l.enrichAudioIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
|
||||
if videoRefs := collectRefsByKind(messages, mediaRefs, "video"); len(videoRefs) > 0 {
|
||||
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
|
||||
l.enrichVideoIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
|
||||
// knows images were received and stored (consistent with audio/video enrichment).
|
||||
l.enrichImageIDs(messages, mediaRefs)
|
||||
|
||||
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
|
||||
// This enables read_image(path=...) for both current and historical images.
|
||||
if deferToReadImageTool {
|
||||
l.enrichImagePaths(messages)
|
||||
}
|
||||
|
||||
// 2f. Collect all media file paths for team workspace auto-collect.
|
||||
// When the leader calls team_tasks(create), these paths are copied to the
|
||||
// team workspace so members can access attached files.
|
||||
if len(mediaRefs) > 0 && l.mediaStore != nil {
|
||||
var mediaPaths []string
|
||||
for _, ref := range mediaRefs {
|
||||
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
|
||||
if ref.Path != "" {
|
||||
mediaPaths = append(mediaPaths, ref.Path)
|
||||
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
mediaPaths = append(mediaPaths, p)
|
||||
}
|
||||
}
|
||||
if len(mediaPaths) > 0 {
|
||||
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
|
||||
// Extract original filenames from <media:document name="X" path="Y"> tags
|
||||
// in the last user message (enriched in step 2b above).
|
||||
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
|
||||
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
|
||||
ctx = tools.WithRunMediaNames(ctx, nameMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, messages, mediaRefs
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -86,7 +86,7 @@ type Loop struct {
|
||||
|
||||
eventPub bus.EventPublisher // currently unused by Loop; kept for future use
|
||||
sessions store.SessionStore
|
||||
tools *tools.Registry
|
||||
tools tools.ToolExecutor
|
||||
toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM
|
||||
agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions)
|
||||
activeRuns atomic.Int32 // number of currently executing runs
|
||||
|
||||
+118
-83
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Determine if caller is a lead or a member.
|
||||
isLead := agentID == team.LeadAgentID
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel == ChannelTeammate || channel == ChannelSystem {
|
||||
isLead = true // system/teammate channels act on behalf of the lead
|
||||
}
|
||||
|
||||
taskType, _ := args["task_type"].(string)
|
||||
if taskType == "" {
|
||||
taskType = "general"
|
||||
}
|
||||
|
||||
if !isLead {
|
||||
// Members may only create "request" tasks when the feature is enabled.
|
||||
memberCfg := ParseMemberRequestConfig(team.Settings)
|
||||
if !memberCfg.Enabled {
|
||||
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
|
||||
}
|
||||
if taskType != "request" {
|
||||
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
|
||||
}
|
||||
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
|
||||
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
|
||||
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
|
||||
}
|
||||
|
||||
subject, _ := args["subject"].(string)
|
||||
if subject == "" {
|
||||
return ErrorResult("subject is required for create action")
|
||||
}
|
||||
|
||||
description, _ := args["description"].(string)
|
||||
priority := 0
|
||||
if p, ok := args["priority"].(float64); ok {
|
||||
priority = int(p)
|
||||
}
|
||||
|
||||
var blockedBy []uuid.UUID
|
||||
if raw, ok := args["blocked_by"].([]any); ok {
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
|
||||
}
|
||||
blockedBy = append(blockedBy, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all blocked_by tasks belong to the same team and are not terminal.
|
||||
for _, depID := range blockedBy {
|
||||
depTask, err := t.manager.Store().GetTask(ctx, depID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
|
||||
}
|
||||
if depTask.TeamID != team.ID {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
|
||||
}
|
||||
switch depTask.Status {
|
||||
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
"blocked_by task %s (%s) is already %s. "+
|
||||
"Do not block on finished tasks — create this task without blocked_by, "+
|
||||
"or pass the completed task's result in the description instead.",
|
||||
depID, depTask.Subject, depTask.Status))
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
|
||||
assigneeKey, _ := args["assignee"].(string)
|
||||
if assigneeKey == "" {
|
||||
return ErrorResult("assignee is required — specify which team member should handle this task")
|
||||
}
|
||||
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
|
||||
}
|
||||
// Verify assignee is a member of this team.
|
||||
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to verify team membership: " + err.Error())
|
||||
}
|
||||
isMember := false
|
||||
for _, m := range members {
|
||||
if m.AgentID == assigneeID {
|
||||
isMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMember {
|
||||
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
|
||||
}
|
||||
// Prevent lead from self-assigning — causes dual-session execution + loop.
|
||||
// buildCreateHint already hides the lead from the member list hint,
|
||||
// but weaker models may still attempt self-assignment.
|
||||
if assigneeID == team.LeadAgentID {
|
||||
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
|
||||
}
|
||||
|
||||
requireApproval, _ := args["require_approval"].(bool)
|
||||
status := store.TeamTaskStatusPending
|
||||
if requireApproval {
|
||||
status = store.TeamTaskStatusInReview
|
||||
} else if len(blockedBy) > 0 {
|
||||
status = store.TeamTaskStatusBlocked
|
||||
}
|
||||
// Assigned tasks without blockers stay pending — dispatched after the turn
|
||||
// ends via post-turn processing (avoids race with blocked_by setup).
|
||||
|
||||
// Member requests without auto_dispatch stay pending for leader review.
|
||||
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
|
||||
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
|
||||
|
||||
chatID := ToolChatIDFromCtx(ctx)
|
||||
|
||||
// Compute team workspace via layered pipeline: tenant → team → user/chat.
|
||||
shared := IsSharedWorkspace(team.Settings)
|
||||
taskMeta := make(map[string]any)
|
||||
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
|
||||
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
|
||||
TeamLayer(team.ID),
|
||||
UserChatLayer(chatID, shared),
|
||||
)
|
||||
taskMeta[TaskMetaTeamWorkspace] = teamWsDir
|
||||
// Auto-collect media files from current run to team workspace.
|
||||
// When leader received files from user and creates a task, copy those
|
||||
// files to the team workspace so members can access them via read_file.
|
||||
// Also rewrite any media paths in the description to point to the workspace copy,
|
||||
// since members can't access the original .media/ paths outside their workspace.
|
||||
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
|
||||
if wsDir, _ := taskMeta[TaskMetaTeamWorkspace].(string); wsDir != "" {
|
||||
nameMap := RunMediaNamesFromCtx(ctx)
|
||||
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
|
||||
// Store as []any so type assertion works both before and after JSON round-trip.
|
||||
files := make([]any, len(copiedPaths))
|
||||
for i, p := range copiedPaths {
|
||||
files[i] = p
|
||||
}
|
||||
taskMeta["attached_files"] = files
|
||||
|
||||
// Rewrite media paths in description so members see workspace paths.
|
||||
for i, src := range mediaPaths {
|
||||
if i < len(copiedPaths) {
|
||||
description = strings.ReplaceAll(description, src, copiedPaths[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
|
||||
if len(blockedBy) > 0 {
|
||||
ids := make([]string, len(blockedBy))
|
||||
for i, id := range blockedBy {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
taskMeta["original_blocked_by"] = ids
|
||||
}
|
||||
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
|
||||
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
|
||||
taskMeta[TaskMetaPeerKind] = pk
|
||||
}
|
||||
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
|
||||
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
|
||||
taskMeta[TaskMetaLocalKey] = lk
|
||||
}
|
||||
// Store origin session key so deferred dispatches route announces correctly.
|
||||
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
|
||||
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
|
||||
taskMeta[TaskMetaOriginSession] = sk
|
||||
}
|
||||
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
|
||||
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
|
||||
taskMeta[TaskMetaOriginTrace] = traceID.String()
|
||||
}
|
||||
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
|
||||
taskMeta[TaskMetaOriginRootSpan] = rootSpanID.String()
|
||||
}
|
||||
|
||||
task := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
Status: status,
|
||||
BlockedBy: blockedBy,
|
||||
Priority: priority,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TaskType: taskType,
|
||||
CreatedByAgentID: &agentID,
|
||||
ChatID: chatID,
|
||||
Metadata: taskMeta,
|
||||
}
|
||||
task.OwnerAgentID = &assigneeID
|
||||
|
||||
// Auto-link member request to the member's current task as parent.
|
||||
if !isLead && taskType == "request" {
|
||||
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
|
||||
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
task.ParentID = &parentUUID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
|
||||
return ErrorResult("failed to create task: " + err.Error())
|
||||
}
|
||||
|
||||
// Persist media files copied during task creation as DB attachments,
|
||||
// so the UI and queries see them in team_task_attachments table.
|
||||
// (Members get auto-attached via WorkspaceInterceptor, but leaders
|
||||
// don't run inside a task context — handle explicitly here.)
|
||||
if files, ok := taskMeta["attached_files"].([]any); ok {
|
||||
for _, f := range files {
|
||||
filePath, ok := f.(string)
|
||||
if !ok || filePath == "" {
|
||||
continue
|
||||
}
|
||||
var fileSize int64
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
fileSize = info.Size()
|
||||
}
|
||||
att := &store.TeamTaskAttachmentData{
|
||||
TaskID: task.ID,
|
||||
TeamID: team.ID,
|
||||
ChatID: chatID,
|
||||
Path: filePath,
|
||||
FileSize: fileSize,
|
||||
MimeType: mimeFromExt(filepath.Ext(filePath)),
|
||||
CreatedByAgentID: &agentID,
|
||||
}
|
||||
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
|
||||
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
|
||||
team.ID.String(), task.ID.String(),
|
||||
status,
|
||||
"agent", agentKey,
|
||||
WithSubject(subject),
|
||||
WithContextInfo(ctx),
|
||||
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
|
||||
))
|
||||
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
|
||||
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
|
||||
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
|
||||
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
|
||||
ptd.Add(team.ID, task.ID)
|
||||
} else {
|
||||
// Fallback: assign (pending → in_progress + lock) then dispatch.
|
||||
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
|
||||
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
|
||||
} else {
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
|
||||
team.ID.String(), task.ID.String(),
|
||||
store.TeamTaskStatusInProgress,
|
||||
"system", "fallback_dispatch",
|
||||
WithTaskInfo(task.TaskNumber, task.Subject),
|
||||
WithOwnerAgentKey(t.manager.AgentKeyFromID(ctx, assigneeID)),
|
||||
WithChannel(task.Channel),
|
||||
WithChatID(task.ChatID),
|
||||
))
|
||||
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
|
||||
if assigneeName == "" {
|
||||
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
|
||||
}
|
||||
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
|
||||
|
||||
// Soft guardrail: warn if subject suggests multiple deliverables.
|
||||
// Only checks subject (not description) — detailed descriptions are fine.
|
||||
if subject != "" {
|
||||
subjLower := strings.ToLower(subject)
|
||||
hasCompound := strings.Contains(subjLower, " and ") &&
|
||||
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
|
||||
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
|
||||
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
|
||||
if hasCompound {
|
||||
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
|
||||
}
|
||||
}
|
||||
return NewResult(msg)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -4,335 +4,30 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
|
||||
// resolveTeamAndTask is the common preamble for task mutation actions:
|
||||
// resolve team context + parse task ID from args.
|
||||
func (t *TeamTasksTool) resolveTeamAndTask(ctx context.Context, args map[string]any) (*store.TeamData, uuid.UUID, uuid.UUID, error) {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
return nil, uuid.Nil, uuid.Nil, err
|
||||
}
|
||||
|
||||
// Determine if caller is a lead or a member.
|
||||
isLead := agentID == team.LeadAgentID
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel == ChannelTeammate || channel == ChannelSystem {
|
||||
isLead = true // system/teammate channels act on behalf of the lead
|
||||
}
|
||||
|
||||
taskType, _ := args["task_type"].(string)
|
||||
if taskType == "" {
|
||||
taskType = "general"
|
||||
}
|
||||
|
||||
if !isLead {
|
||||
// Members may only create "request" tasks when the feature is enabled.
|
||||
memberCfg := ParseMemberRequestConfig(team.Settings)
|
||||
if !memberCfg.Enabled {
|
||||
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
|
||||
}
|
||||
if taskType != "request" {
|
||||
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
|
||||
}
|
||||
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
|
||||
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
|
||||
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
|
||||
}
|
||||
|
||||
subject, _ := args["subject"].(string)
|
||||
if subject == "" {
|
||||
return ErrorResult("subject is required for create action")
|
||||
}
|
||||
|
||||
description, _ := args["description"].(string)
|
||||
priority := 0
|
||||
if p, ok := args["priority"].(float64); ok {
|
||||
priority = int(p)
|
||||
}
|
||||
|
||||
var blockedBy []uuid.UUID
|
||||
if raw, ok := args["blocked_by"].([]any); ok {
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
|
||||
}
|
||||
blockedBy = append(blockedBy, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all blocked_by tasks belong to the same team and are not terminal.
|
||||
for _, depID := range blockedBy {
|
||||
depTask, err := t.manager.Store().GetTask(ctx, depID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
|
||||
}
|
||||
if depTask.TeamID != team.ID {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
|
||||
}
|
||||
switch depTask.Status {
|
||||
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
"blocked_by task %s (%s) is already %s. "+
|
||||
"Do not block on finished tasks — create this task without blocked_by, "+
|
||||
"or pass the completed task's result in the description instead.",
|
||||
depID, depTask.Subject, depTask.Status))
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
|
||||
assigneeKey, _ := args["assignee"].(string)
|
||||
if assigneeKey == "" {
|
||||
return ErrorResult("assignee is required — specify which team member should handle this task")
|
||||
}
|
||||
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
|
||||
taskID, err := resolveTaskID(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
|
||||
return nil, uuid.Nil, uuid.Nil, err
|
||||
}
|
||||
// Verify assignee is a member of this team.
|
||||
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to verify team membership: " + err.Error())
|
||||
}
|
||||
isMember := false
|
||||
for _, m := range members {
|
||||
if m.AgentID == assigneeID {
|
||||
isMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMember {
|
||||
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
|
||||
}
|
||||
// Prevent lead from self-assigning — causes dual-session execution + loop.
|
||||
// buildCreateHint already hides the lead from the member list hint,
|
||||
// but weaker models may still attempt self-assignment.
|
||||
if assigneeID == team.LeadAgentID {
|
||||
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
|
||||
}
|
||||
|
||||
requireApproval, _ := args["require_approval"].(bool)
|
||||
status := store.TeamTaskStatusPending
|
||||
if requireApproval {
|
||||
status = store.TeamTaskStatusInReview
|
||||
} else if len(blockedBy) > 0 {
|
||||
status = store.TeamTaskStatusBlocked
|
||||
}
|
||||
// Assigned tasks without blockers stay pending — dispatched after the turn
|
||||
// ends via post-turn processing (avoids race with blocked_by setup).
|
||||
|
||||
// Member requests without auto_dispatch stay pending for leader review.
|
||||
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
|
||||
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
|
||||
|
||||
chatID := ToolChatIDFromCtx(ctx)
|
||||
|
||||
// Compute team workspace via layered pipeline: tenant → team → user/chat.
|
||||
shared := IsSharedWorkspace(team.Settings)
|
||||
taskMeta := make(map[string]any)
|
||||
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
|
||||
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
|
||||
TeamLayer(team.ID),
|
||||
UserChatLayer(chatID, shared),
|
||||
)
|
||||
taskMeta["team_workspace"] = teamWsDir
|
||||
// Auto-collect media files from current run to team workspace.
|
||||
// When leader received files from user and creates a task, copy those
|
||||
// files to the team workspace so members can access them via read_file.
|
||||
// Also rewrite any media paths in the description to point to the workspace copy,
|
||||
// since members can't access the original .media/ paths outside their workspace.
|
||||
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
|
||||
if wsDir, _ := taskMeta["team_workspace"].(string); wsDir != "" {
|
||||
nameMap := RunMediaNamesFromCtx(ctx)
|
||||
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
|
||||
// Store as []any so type assertion works both before and after JSON round-trip.
|
||||
files := make([]any, len(copiedPaths))
|
||||
for i, p := range copiedPaths {
|
||||
files[i] = p
|
||||
}
|
||||
taskMeta["attached_files"] = files
|
||||
|
||||
// Rewrite media paths in description so members see workspace paths.
|
||||
for i, src := range mediaPaths {
|
||||
if i < len(copiedPaths) {
|
||||
description = strings.ReplaceAll(description, src, copiedPaths[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
|
||||
if len(blockedBy) > 0 {
|
||||
ids := make([]string, len(blockedBy))
|
||||
for i, id := range blockedBy {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
taskMeta["original_blocked_by"] = ids
|
||||
}
|
||||
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
|
||||
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
|
||||
taskMeta["peer_kind"] = pk
|
||||
}
|
||||
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
|
||||
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
|
||||
taskMeta["local_key"] = lk
|
||||
}
|
||||
// Store origin session key so deferred dispatches route announces correctly.
|
||||
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
|
||||
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
|
||||
taskMeta["origin_session_key"] = sk
|
||||
}
|
||||
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
|
||||
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
|
||||
taskMeta["origin_trace_id"] = traceID.String()
|
||||
}
|
||||
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
|
||||
taskMeta["origin_root_span_id"] = rootSpanID.String()
|
||||
}
|
||||
|
||||
task := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
Status: status,
|
||||
BlockedBy: blockedBy,
|
||||
Priority: priority,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TaskType: taskType,
|
||||
CreatedByAgentID: &agentID,
|
||||
ChatID: chatID,
|
||||
Metadata: taskMeta,
|
||||
}
|
||||
task.OwnerAgentID = &assigneeID
|
||||
|
||||
// Auto-link member request to the member's current task as parent.
|
||||
if !isLead && taskType == "request" {
|
||||
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
|
||||
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
task.ParentID = &parentUUID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
|
||||
return ErrorResult("failed to create task: " + err.Error())
|
||||
}
|
||||
|
||||
// Persist media files copied during task creation as DB attachments,
|
||||
// so the UI and queries see them in team_task_attachments table.
|
||||
// (Members get auto-attached via WorkspaceInterceptor, but leaders
|
||||
// don't run inside a task context — handle explicitly here.)
|
||||
if files, ok := taskMeta["attached_files"].([]any); ok {
|
||||
for _, f := range files {
|
||||
filePath, ok := f.(string)
|
||||
if !ok || filePath == "" {
|
||||
continue
|
||||
}
|
||||
var fileSize int64
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
fileSize = info.Size()
|
||||
}
|
||||
att := &store.TeamTaskAttachmentData{
|
||||
TaskID: task.ID,
|
||||
TeamID: team.ID,
|
||||
ChatID: chatID,
|
||||
Path: filePath,
|
||||
FileSize: fileSize,
|
||||
MimeType: mimeFromExt(filepath.Ext(filePath)),
|
||||
CreatedByAgentID: &agentID,
|
||||
}
|
||||
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
|
||||
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: task.ID.String(),
|
||||
Subject: subject,
|
||||
Status: status,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: chatID,
|
||||
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: agentKey,
|
||||
})
|
||||
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
|
||||
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
|
||||
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
|
||||
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
|
||||
ptd.Add(team.ID, task.ID)
|
||||
} else {
|
||||
// Fallback: assign (pending → in_progress + lock) then dispatch.
|
||||
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
|
||||
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
|
||||
} else {
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: task.ID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentKey: t.manager.AgentKeyFromID(ctx, assigneeID),
|
||||
Channel: task.Channel,
|
||||
ChatID: task.ChatID,
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "system",
|
||||
ActorID: "fallback_dispatch",
|
||||
})
|
||||
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
|
||||
if assigneeName == "" {
|
||||
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
|
||||
}
|
||||
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
|
||||
|
||||
// Soft guardrail: warn if subject suggests multiple deliverables.
|
||||
// Only checks subject (not description) — detailed descriptions are fine.
|
||||
if subject != "" {
|
||||
subjLower := strings.ToLower(subject)
|
||||
hasCompound := strings.Contains(subjLower, " and ") &&
|
||||
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
|
||||
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
|
||||
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
|
||||
if hasCompound {
|
||||
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
|
||||
}
|
||||
}
|
||||
return NewResult(msg)
|
||||
return team, agentID, taskID, nil
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskID, err := resolveTaskID(ctx, args)
|
||||
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
@@ -368,19 +63,14 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to add comment: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
CommentText: truncatePreview(text, 500),
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
|
||||
})
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, BuildTaskEventPayload(
|
||||
team.ID.String(), taskID.String(),
|
||||
"",
|
||||
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
||||
WithTaskInfo(task.TaskNumber, task.Subject),
|
||||
WithCommentText(truncatePreview(text, 500)),
|
||||
WithContextInfo(ctx),
|
||||
))
|
||||
|
||||
// Record action flag after successful store operation.
|
||||
recordTaskAction(ctx, func(f *TaskActionFlags) {
|
||||
@@ -410,14 +100,8 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
|
||||
return NewResult(msg)
|
||||
}
|
||||
|
||||
|
||||
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskID, err := resolveTaskID(ctx, args)
|
||||
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
@@ -444,7 +128,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
|
||||
}
|
||||
|
||||
// Early exit: task already terminal — skip DB write entirely.
|
||||
// Reuses the task fetched above (line 391) so no extra query needed.
|
||||
// Reuses the task fetched above so no extra query needed.
|
||||
switch task.Status {
|
||||
case store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled:
|
||||
return SilentResult(fmt.Sprintf("Task already %s — progress update skipped.", task.Status))
|
||||
@@ -456,7 +140,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
|
||||
}
|
||||
|
||||
if err := t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
|
||||
// Status may have changed between GetTask and UpdateTaskProgress.
|
||||
// Status may have changed between GetTask and UpdateTaskProgress (race with completeTask).
|
||||
// Fast path: check in-memory turn flags before hitting DB again.
|
||||
if flags := TaskActionFlagsFromCtx(ctx); flags != nil && flags.Completed {
|
||||
return SilentResult("Task already completed — progress update skipped.")
|
||||
@@ -486,31 +170,21 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
|
||||
if task.OwnerAgentID != nil {
|
||||
ownerKey = t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
|
||||
}
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentKey: ownerKey,
|
||||
ProgressPercent: percent,
|
||||
ProgressStep: step,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, BuildTaskEventPayload(
|
||||
team.ID.String(), taskID.String(),
|
||||
store.TeamTaskStatusInProgress,
|
||||
"", "",
|
||||
WithTaskInfo(task.TaskNumber, task.Subject),
|
||||
WithOwnerAgentKey(ownerKey),
|
||||
WithProgress(percent, step),
|
||||
WithContextInfo(ctx),
|
||||
))
|
||||
|
||||
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskID, err := resolveTaskID(ctx, args)
|
||||
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
@@ -548,24 +222,19 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to attach file: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: chatID,
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
|
||||
})
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, BuildTaskEventPayload(
|
||||
team.ID.String(), taskID.String(),
|
||||
"",
|
||||
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
||||
WithTaskInfo(task.TaskNumber, task.Subject),
|
||||
WithContextInfo(ctx),
|
||||
))
|
||||
|
||||
return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.ResolveTeam(ctx)
|
||||
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
@@ -573,11 +242,6 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskID, err := resolveTaskID(ctx, args)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Verify task belongs to this team (prevent cross-team update).
|
||||
task, err := t.manager.Store().GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
@@ -642,18 +306,13 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to update task: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Subject: task.Subject,
|
||||
Status: task.Status,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: t.manager.AgentKeyFromID(ctx, agentID),
|
||||
})
|
||||
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, BuildTaskEventPayload(
|
||||
team.ID.String(), taskID.String(),
|
||||
task.Status,
|
||||
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
||||
WithSubject(task.Subject),
|
||||
WithContextInfo(ctx),
|
||||
))
|
||||
|
||||
return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user