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
417 lines
15 KiB
Go
417 lines
15 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"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/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// processNormalMessage handles routing, scheduling, and response delivery for a single
|
|
// (possibly merged) inbound message. Called directly by the debouncer's flush callback.
|
|
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,
|
|
) {
|
|
// Determine target agent via bindings or explicit AgentID
|
|
agentID := msg.AgentID
|
|
if agentID == "" {
|
|
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
|
|
}
|
|
|
|
agentLoop, err := agents.Get(agentID)
|
|
if err != nil {
|
|
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
|
|
return
|
|
}
|
|
|
|
// Build session key based on scope config (matching TS buildAgentPeerSessionKey).
|
|
peerKind := msg.PeerKind
|
|
if peerKind == "" {
|
|
peerKind = string(sessions.PeerDirect) // default to DM
|
|
}
|
|
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
|
|
|
// 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) {
|
|
var topicID int
|
|
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
|
|
if topicID > 0 {
|
|
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
|
|
}
|
|
}
|
|
|
|
// DM thread: override session key to isolate per-thread history in private chats.
|
|
if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
|
|
var threadID int
|
|
fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
|
|
if threadID > 0 {
|
|
sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
|
|
}
|
|
}
|
|
|
|
// Group-scoped UserID: context files, memory, traces, and seeding scope.
|
|
// - Discord guilds: "guild:{guildID}:user:{senderID}" — per-user per-server,
|
|
// shared across all channels within the same server. Session key stays per-channel.
|
|
// - Other platforms: "group:{channel}:{chatID}" — shared by all users in the chat.
|
|
// Individual senderID is preserved in InboundMessage for pairing/dedup/mention gate.
|
|
userID := msg.UserID
|
|
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
|
|
if guildID := msg.Metadata["guild_id"]; guildID != "" && msg.SenderID != "" {
|
|
// Discord guild: per-user scope so each member has own profile
|
|
// across all channels in the same server.
|
|
userID = fmt.Sprintf("guild:%s:user:%s", guildID, msg.SenderID)
|
|
} else {
|
|
groupID := msg.ChatID
|
|
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
|
|
}
|
|
}
|
|
|
|
// Persist friendly names from channel metadata into session + user profile.
|
|
sessionMeta := extractSessionMetadata(msg, peerKind)
|
|
if len(sessionMeta) > 0 {
|
|
sessStore.SetSessionMetadata(sessionKey, sessionMeta)
|
|
if agentStore != nil {
|
|
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
|
|
_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-collect channel contacts for the contact selector.
|
|
if contactCollector != nil && msg.SenderID != "" {
|
|
senderNumericID := msg.SenderID
|
|
if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 {
|
|
senderNumericID = senderNumericID[:idx]
|
|
}
|
|
channelType := 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)
|
|
}
|
|
|
|
// --- Quota check ---
|
|
if quotaChecker != nil {
|
|
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
|
|
if !qResult.Allowed {
|
|
slog.Warn("security.quota_exceeded",
|
|
"user_id", userID,
|
|
"channel", msg.Channel,
|
|
"window", qResult.Window,
|
|
"used", qResult.Used,
|
|
"limit", qResult.Limit,
|
|
)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: formatQuotaExceeded(qResult),
|
|
Metadata: msg.Metadata,
|
|
})
|
|
return
|
|
}
|
|
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 {
|
|
go func(ch, cid string) {
|
|
if n, err := 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)
|
|
}
|
|
}(msg.Channel, msg.ChatID)
|
|
}
|
|
|
|
slog.Info("inbound: scheduling message (main lane)",
|
|
"channel", msg.Channel,
|
|
"chat_id", msg.ChatID,
|
|
"peer_kind", peerKind,
|
|
"agent", agentID,
|
|
"session", sessionKey,
|
|
"user_id", userID,
|
|
)
|
|
|
|
// 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)
|
|
|
|
// Group chats allow concurrent runs (multiple users can chat simultaneously).
|
|
maxConcurrent := 1
|
|
if peerKind == string(sessions.PeerGroup) {
|
|
maxConcurrent = 3
|
|
}
|
|
|
|
runID := fmt.Sprintf("inbound-%s-%s-%s", msg.Channel, msg.ChatID, uuid.NewString()[:8])
|
|
|
|
// Build outbound metadata for reply-to + thread routing BEFORE RegisterRun
|
|
// so block.reply handler can use it for routing intermediate messages.
|
|
outMeta := make(map[string]string)
|
|
if isGroup {
|
|
if mid := msg.Metadata["message_id"]; mid != "" {
|
|
outMeta["reply_to_message_id"] = mid
|
|
}
|
|
}
|
|
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key", "group_id"} {
|
|
if v := msg.Metadata[k]; v != "" {
|
|
outMeta[k] = v
|
|
}
|
|
}
|
|
|
|
// Register run with channel manager for streaming/reaction event forwarding.
|
|
// Use localKey (composite key with topic suffix) so streaming/reaction events
|
|
// route to the correct per-topic state in the channel.
|
|
messageID := msg.Metadata["message_id"]
|
|
chatIDForRun := msg.ChatID
|
|
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)
|
|
}
|
|
|
|
// Group-aware system prompt: help the LLM adapt tone and behavior for group chats.
|
|
var extraPrompt string
|
|
if peerKind == string(sessions.PeerGroup) {
|
|
extraPrompt = "You are in a GROUP chat (multiple participants), not a private 1-on-1 DM.\n" +
|
|
"- Messages may include a [Chat messages since your last reply] section with recent group history. Each history line shows \"sender [time]: message\".\n" +
|
|
"- The current message includes a [From: sender_name] tag identifying who @mentioned you.\n" +
|
|
"- Keep responses concise and focused; long replies are disruptive in groups.\n" +
|
|
"- Address the group naturally. If the history shows a multi-person conversation, consider the full context before answering."
|
|
}
|
|
|
|
// Append per-topic system prompt (from group/topic config hierarchy).
|
|
if tsp := msg.Metadata["topic_system_prompt"]; tsp != "" {
|
|
if extraPrompt != "" {
|
|
extraPrompt += "\n\n"
|
|
}
|
|
extraPrompt += tsp
|
|
}
|
|
|
|
// Per-topic skill filter override (from group/topic config hierarchy).
|
|
var skillFilter []string
|
|
if ts := msg.Metadata["topic_skills"]; ts != "" {
|
|
skillFilter = strings.Split(ts, ",")
|
|
}
|
|
|
|
// Delegation announces carry media as ForwardMedia (not deleted, forwarded to output).
|
|
// User-uploaded media goes in Media (loaded as images for LLM, then deleted).
|
|
var reqMedia, fwdMedia []bus.MediaFile
|
|
if msg.Metadata["delegation_id"] != "" || msg.Metadata["subagent_id"] != "" {
|
|
fwdMedia = msg.Media
|
|
} else {
|
|
reqMedia = msg.Media
|
|
}
|
|
|
|
// 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 loop, ok := agentLoop.(*agent.Loop); ok && loop.Provider() != nil {
|
|
locale := msg.Metadata["locale"]
|
|
if locale == "" {
|
|
locale = "en"
|
|
}
|
|
intent := agent.ClassifyIntent(ctx, loop.Provider(), loop.Model(), msg.Content)
|
|
switch intent {
|
|
case agent.IntentStatusQuery:
|
|
status := agents.GetActivity(sessionKey)
|
|
reply := agent.FormatStatusReply(status, locale)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: reply,
|
|
Metadata: outMeta,
|
|
})
|
|
return
|
|
case agent.IntentCancel:
|
|
aborted := agents.AbortRunsForSession(sessionKey)
|
|
if len(aborted) > 0 {
|
|
slog.Info("inbound: cancelled runs via intent classify",
|
|
"session", sessionKey, "aborted", aborted)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: i18n.T(locale, i18n.MsgCancelledReply),
|
|
Metadata: outMeta,
|
|
})
|
|
}
|
|
return
|
|
case agent.IntentSteer, agent.IntentNewTask:
|
|
// Mid-run injection: inject into the running loop instead of queueing.
|
|
injected := agents.InjectMessage(sessionKey, agent.InjectedMessage{
|
|
Content: msg.Content,
|
|
UserID: userID,
|
|
})
|
|
if injected {
|
|
slog.Info("inbound: injected mid-run message",
|
|
"intent", string(intent), "session", sessionKey)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: i18n.T(locale, i18n.MsgInjectedAck),
|
|
Metadata: outMeta,
|
|
})
|
|
return
|
|
}
|
|
// Fallback: injection failed (channel full) → fall through to scheduler queue
|
|
slog.Info("inbound: injection failed, queueing as normal",
|
|
"intent", string(intent), "session", sessionKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject post-turn dispatch tracker so team task creates are deferred.
|
|
ptd := tools.NewPendingTeamDispatch()
|
|
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
|
|
|
|
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
|
|
outCh := sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
|
|
SessionKey: sessionKey,
|
|
Message: msg.Content,
|
|
Media: reqMedia,
|
|
ForwardMedia: fwdMedia,
|
|
Channel: msg.Channel,
|
|
ChannelType: resolveChannelType(channelMgr, msg.Channel),
|
|
ChatID: msg.ChatID,
|
|
PeerKind: peerKind,
|
|
LocalKey: msg.Metadata["local_key"],
|
|
UserID: userID,
|
|
SenderID: msg.SenderID,
|
|
RunID: runID,
|
|
Stream: enableStream,
|
|
HistoryLimit: msg.HistoryLimit,
|
|
ToolAllow: msg.ToolAllow,
|
|
ExtraSystemPrompt: extraPrompt,
|
|
SkillFilter: skillFilter,
|
|
}, scheduler.ScheduleOpts{
|
|
MaxConcurrent: maxConcurrent,
|
|
})
|
|
|
|
// Handle result asynchronously to not block the flush callback.
|
|
go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) {
|
|
outcome := <-outCh
|
|
|
|
// Release team create lock — tasks already visible in DB, other goroutines can list.
|
|
ptd.ReleaseTeamLock()
|
|
|
|
// Post-turn: dispatch pending team tasks created during this turn.
|
|
if postTurn != nil {
|
|
for teamID, taskIDs := range ptd.Drain() {
|
|
if err := 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 outcome.Err != nil {
|
|
// Don't send error for cancelled runs (/stop command) —
|
|
// 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{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: "",
|
|
Metadata: meta,
|
|
})
|
|
return
|
|
}
|
|
slog.Error("inbound: agent run failed", "error", outcome.Err, "channel", channel)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: formatAgentError(outcome.Err),
|
|
Metadata: meta,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Suppress empty/NO_REPLY responses (matching TS normalize-reply.ts).
|
|
// Still publish an empty outbound so channels can clean up placeholder/thinking indicators.
|
|
if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) {
|
|
slog.Info("inbound: suppressed silent/empty reply",
|
|
"channel", channel,
|
|
"chat_id", chatID,
|
|
"session", session,
|
|
)
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: "",
|
|
Metadata: meta,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Dedup: if block replies were delivered and the final content matches the last
|
|
// block reply, suppress the final message to avoid duplicate delivery.
|
|
// Only applies when blockReply is enabled (otherwise nothing was delivered).
|
|
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{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: "",
|
|
Metadata: meta,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Publish response back to the channel
|
|
outMsg := bus.OutboundMessage{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: outcome.Result.Content,
|
|
Metadata: meta,
|
|
}
|
|
|
|
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
|
|
|
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, outcome.Result.Content)
|
|
}
|
|
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply, ptd)
|
|
}
|