mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 06:10:46 +00:00
49441f7305
- Remove handleDelegateAnnounce() dead code (no sender emits delegate:* messages) - Remove delegate tool reference from intent_classify.go - Rename LaneDelegate → LaneTeam with backward-compat env var fallback - Rename ChannelDelegate → ChannelTeammate across all team tool files - Comment out lifecycle guards in team_tasks_lifecycle.go (TODO: reviewer workflow) - Update string literals in cron.go, task_ticker.go - Gate tool_status placeholder_update to non-streaming runs only - Skip FinalizeStream on tool.call to prevent mid-run content loss
215 lines
7.9 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|