Files
goclaw/cmd/gateway_consumer.go
T
viettranx 49441f7305 refactor: remove dead delegate code, rename lane/channel to team/teammate
- Remove handleDelegateAnnounce() dead code (no sender emits delegate:* messages)
- Remove delegate tool reference from intent_classify.go
- Rename LaneDelegate → LaneTeam with backward-compat env var fallback
- Rename ChannelDelegate → ChannelTeammate across all team tool files
- Comment out lifecycle guards in team_tasks_lifecycle.go (TODO: reviewer workflow)
- Update string literals in cron.go, task_ticker.go
- Gate tool_status placeholder_update to non-streaming runs only
- Skip FinalizeStream on tool.call to prevent mid-run content loss
2026-03-18 11:04:45 +07:00

215 lines
7.9 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"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/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// consumeInboundMessages reads inbound messages from channels (Telegram, Discord, etc.)
// and routes them through the scheduler/agent loop, then publishes the response back.
// Also handles subagent announcements: routes them through the parent agent's session
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, 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) {
slog.Info("inbound message consumer started")
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
// TTL=20min, max=5000 entries — prevents webhook retries / double-taps from duplicating agent runs.
dedupe := bus.NewDedupeCache(20*time.Minute, 5000)
// Per-session announce serialization: prevents concurrent announce runs from
// reading stale session history. Without this, Announce #2 can start while
// Announce #1 is still running, read history that doesn't include Announce #1's
// messages (written only after agent loop completes), and generate responses
// with wrong context (e.g. "waiting for Tiểu La" when Tiểu La already finished).
var announceMu sync.Map // sessionKey → *sync.Mutex
getAnnounceMu := func(key string) *sync.Mutex {
v, _ := announceMu.LoadOrStore(key, &sync.Mutex{})
return v.(*sync.Mutex)
}
// 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
}
payload, ok := event.Payload.(protocol.TeamTaskEventPayload)
if !ok {
return
}
if sessKey, ok := 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)
}
})
// Inbound debounce: merge rapid messages from the same sender before processing.
// Matching TS createInboundDebouncer from src/auto-reply/inbound-debounce.ts.
debounceMs := cfg.Gateway.InboundDebounceMs
if debounceMs == 0 {
debounceMs = 1000 // default: 1000ms
}
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)
},
)
defer debouncer.Stop()
slog.Info("inbound debounce configured", "debounce_ms", debounceMs)
for {
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
slog.Info("inbound message consumer stopped")
return
}
// --- Dedup: skip duplicate inbound messages (matching TS shouldSkipDuplicateInbound) ---
if msgID := msg.Metadata["message_id"]; msgID != "" {
dedupeKey := fmt.Sprintf("%s|%s|%s|%s", msg.Channel, msg.SenderID, msg.ChatID, msgID)
if dedupe.IsDuplicate(dedupeKey) {
slog.Debug("dedup: skipping duplicate message", "key", dedupeKey)
continue
}
}
if handleSubagentAnnounce(ctx, msg, cfg, sched, channelMgr, msgBus, getAnnounceMu) {
continue
}
if handleTeammateMessage(ctx, msg, cfg, sched, channelMgr, teamStore, agentStore, msgBus, postTurn, &taskRunSessions) {
continue
}
if handleResetCommand(msg, cfg, sessStore) {
continue
}
if handleStopCommand(msg, cfg, sched, sessStore, msgBus) {
continue
}
// --- Normal messages: route through debouncer ---
debouncer.Push(msg)
}
}
// autoSetFollowup sets followup reminders on in_progress tasks when the lead agent
// replies on a real channel. Only sets followup if the task doesn't already have one
// (respects LLM-initiated ask_user). Fire-and-forget, logs errors.
func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore store.AgentStore, agentKey, channel, chatID, content string) {
if agentStore == nil {
return
}
// agentKey may be a slug ("default") or a UUID string (from WS clients).
var ag *store.AgentData
var err error
if id, parseErr := uuid.Parse(agentKey); parseErr == nil {
ag, err = agentStore.GetByID(ctx, id)
} else {
ag, err = agentStore.GetByKey(ctx, agentKey)
}
if err != nil || ag == nil {
return
}
team, err := teamStore.GetTeamForAgent(ctx, ag.ID)
if err != nil || team == nil || team.LeadAgentID != ag.ID {
return // only lead agent triggers auto-set
}
// Followup is a v2 feature.
if !isConsumerTeamV2(team) {
return
}
// Skip auto-followup when lead is waiting for teammates (not user).
if hasMember, _ := teamStore.HasActiveMemberTasks(ctx, team.ID, ag.ID); hasMember {
slog.Debug("auto-followup: skipping, active member tasks exist", "team_id", team.ID)
return
}
interval, max := parseFollowupSettings(team)
followupAt := time.Now().Add(interval)
msg := truncateForReminder(content, 200)
n, err := teamStore.SetFollowupForActiveTasks(ctx, team.ID, channel, chatID, followupAt, max, msg)
if err != nil {
slog.Warn("auto-set followup failed", "channel", channel, "chat_id", chatID, "error", err)
} else if n > 0 {
slog.Info("auto-set followup: set", "channel", channel, "chat_id", chatID, "count", n, "followup_at", followupAt)
}
}
// isConsumerTeamV2 delegates to tools.IsTeamV2 for version checking.
var isConsumerTeamV2 = tools.IsTeamV2
// parseFollowupSettings extracts followup interval and max reminders from team settings.
func parseFollowupSettings(team *store.TeamData) (time.Duration, int) {
const (
defaultIntervalMins = 30
defaultMax = 0 // unlimited
)
if team.Settings == nil {
return time.Duration(defaultIntervalMins) * time.Minute, defaultMax
}
var settings map[string]any
if json.Unmarshal(team.Settings, &settings) != nil {
return time.Duration(defaultIntervalMins) * time.Minute, defaultMax
}
interval := defaultIntervalMins
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
interval = int(v)
}
max := defaultMax
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
max = int(v)
}
return time.Duration(interval) * time.Minute, max
}
// truncateForReminder truncates content to maxLen chars, taking the last line as context.
func truncateForReminder(content string, maxLen int) string {
// Use last non-empty line as it's typically the most relevant.
lines := strings.Split(strings.TrimSpace(content), "\n")
msg := lines[len(lines)-1]
if len(msg) > maxLen {
msg = msg[:maxLen] + "..."
}
return msg
}
// appendMediaToOutbound converts agent MediaResults to outbound MediaAttachments
// on the given OutboundMessage. Handles voice annotation when applicable.
func appendMediaToOutbound(msg *bus.OutboundMessage, media []agent.MediaResult) {
for _, mr := range media {
msg.Media = append(msg.Media, bus.MediaAttachment{
URL: mr.Path,
ContentType: mr.ContentType,
})
if mr.AsVoice {
if msg.Metadata == nil {
msg.Metadata = make(map[string]string)
}
msg.Metadata["audio_as_voice"] = "true"
}
}
}