feat(agent): include Discord channel name in dynamic context

Add ChannelName field to InboundMessage and include it in the agent's
dynamic context. This helps the agent understand the conversation environment
better when processing messages from Discord.

Changes:
- Add ChannelName field to bus.InboundMessage
- Add HandleMessageWithChannelName to BaseChannel for channels that support it
- Update Discord handler to fetch and pass channel name
- Update buildDynamicContext to include channel name in session info
- Update all callers of BuildMessages to support the new parameter

Fixes #1451

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
曾文锋0668000834 2026-03-13 09:48:59 +08:00
parent 19835b2f60
commit afbdc1b455
6 changed files with 205 additions and 154 deletions

View file

@ -12,11 +12,9 @@ import (
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
type ContextBuilder struct {
@ -82,10 +80,8 @@ func NewContextBuilder(workspace string) *ContextBuilder {
func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
version := config.FormatVersion()
return fmt.Sprintf(
`# picoclaw 🦞 (%s)
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
@ -106,7 +102,7 @@ Your workspace is at: %s
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
%s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
}
func (cb *ContextBuilder) getDiscoveryRule() string {
@ -458,7 +454,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
//
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
// See: https://platform.openai.com/docs/guides/prompt-caching
func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
func (cb *ContextBuilder) buildDynamicContext(channel, channelName, chatID string) string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
@ -466,7 +462,11 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
if channel != "" && chatID != "" {
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s", channel)
if channelName != "" {
fmt.Fprintf(&sb, "\nChannel Name: %s", channelName)
}
fmt.Fprintf(&sb, "\nChat ID: %s", chatID)
}
return sb.String()
@ -477,7 +477,7 @@ func (cb *ContextBuilder) BuildMessages(
summary string,
currentMessage string,
media []string,
channel, chatID string,
channel, channelName, chatID string,
) []providers.Message {
messages := []providers.Message{}
@ -493,7 +493,7 @@ func (cb *ContextBuilder) BuildMessages(
staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request
dynamicCtx := cb.buildDynamicContext(channel, chatID)
dynamicCtx := cb.buildDynamicContext(channel, channelName, chatID)
// Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can
@ -539,7 +539,10 @@ func (cb *ContextBuilder) BuildMessages(
})
// Log preview of system prompt (avoid logging huge content)
preview := utils.Truncate(fullSystemPrompt, 500)
preview := fullSystemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
map[string]any{
"preview": preview,

View file

@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "", "chat1")
systemCount := 0
for _, m := range msgs {
@ -576,7 +576,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
}
// Also exercise BuildMessages concurrently
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "", "chat")
if len(msgs) < 2 {
errs <- "BuildMessages returned fewer than 2 messages"
return
@ -664,6 +664,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "", "test")
}
}

View file

@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
@ -47,13 +48,13 @@ type AgentLoop struct {
mediaStore media.MediaStore
transcriber voice.Transcriber
cmdRegistry *commands.Registry
mcp mcpRuntime
}
// processOptions configures how a message is processed
type processOptions struct {
SessionKey string // Session identifier for history/context
Channel string // Target channel for tool execution
ChannelName string // Human-readable channel name (e.g., Discord channel name)
ChatID string // Target chat ID for tool execution
UserMessage string // User message content (may include prefix)
Media []string // media:// refs from inbound message
@ -224,6 +225,13 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Set up model resolver to use target agent's configured model
subagentManager.SetModelResolver(func(targetAgentID string) string {
if targetAgent, ok := registry.GetAgent(targetAgentID); ok {
return targetAgent.Model
}
return ""
})
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
@ -239,8 +247,119 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true)
if err := al.ensureMCPInitialized(ctx); err != nil {
return err
// Initialize MCP servers for all agents
if al.cfg.Tools.IsToolEnabled("mcp") {
mcpManager := mcp.NewManager()
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
} else {
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
return fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
)
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
}
}
for al.running.Load() {
@ -320,17 +439,6 @@ func (al *AgentLoop) Stop() {
// Close releases resources held by agent session stores. Call after Stop.
func (al *AgentLoop) Close() {
mcpManager := al.mcp.takeManager()
if mcpManager != nil {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}
al.registry.Close()
}
@ -367,10 +475,9 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text.
// Returns the (possibly modified) message and true if audio was transcribed.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage {
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg, false
return msg
}
// Transcribe each audio media ref in order.
@ -394,11 +501,9 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
}
if len(transcriptions) == 0 {
return msg, false
return msg
}
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
// Replace audio annotations sequentially with transcriptions.
idx := 0
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
@ -416,48 +521,7 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
}
msg.Content = newContent
return msg, true
}
// sendTranscriptionFeedback sends feedback to the user with the result of
// audio transcription if the option is enabled. It uses Manager.SendMessage
// which executes synchronously (rate limiting, splitting, retry) so that
// ordering with the subsequent placeholder is guaranteed.
func (al *AgentLoop) sendTranscriptionFeedback(
ctx context.Context,
channel, chatID, messageID string,
validTexts []string,
) {
if !al.cfg.Voice.EchoTranscription {
return
}
if al.channelManager == nil {
return
}
var nonEmpty []string
for _, t := range validTexts {
if t != "" {
nonEmpty = append(nonEmpty, t)
}
}
var feedbackMsg string
if len(nonEmpty) > 0 {
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
} else {
feedbackMsg = "No voice detected in the audio"
}
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: feedbackMsg,
ReplyToMessageID: messageID,
})
if err != nil {
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
}
return msg
}
// inferMediaType determines the media type ("image", "audio", "video", "file")
@ -519,10 +583,6 @@ func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context,
content, sessionKey, channel, chatID string,
) (string, error) {
if err := al.ensureMCPInitialized(ctx); err != nil {
return "", err
}
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@ -575,14 +635,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
},
)
var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
msg = al.transcribeAudioInMessage(ctx, msg)
// Route system messages to processSystemMessage
if msg.Channel == "system" {
@ -618,6 +671,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
opts := processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChannelName: msg.ChannelName,
ChatID: msg.ChatID,
UserMessage: msg.Content,
Media: msg.Media,
@ -761,6 +815,7 @@ func (al *AgentLoop) runAgentLoop(
opts.UserMessage,
opts.Media,
opts.Channel,
opts.ChannelName,
opts.ChatID,
)
@ -1029,7 +1084,7 @@ func (al *AgentLoop) runLLMIteration(
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID,
nil, opts.Channel, opts.ChannelName, opts.ChatID,
)
continue
}

View file

@ -16,24 +16,24 @@ type SenderInfo struct {
}
type InboundMessage struct {
Channel string `json:"channel"`
SenderID string `json:"sender_id"`
Sender SenderInfo `json:"sender"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Peer Peer `json:"peer"` // routing peer
MessageID string `json:"message_id,omitempty"` // platform message ID
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
SessionKey string `json:"session_key"`
Metadata map[string]string `json:"metadata,omitempty"`
Channel string `json:"channel"`
ChannelName string `json:"channel_name,omitempty"` // human-readable channel name (e.g., Discord channel name)
SenderID string `json:"sender_id"`
Sender SenderInfo `json:"sender"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Peer Peer `json:"peer"` // routing peer
MessageID string `json:"message_id,omitempty"` // platform message ID
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
SessionKey string `json:"session_key"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
}
// MediaPart describes a single media attachment to send.

View file

@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"regexp"
"strconv"
"strings"
"sync/atomic"
@ -33,9 +32,6 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:])
}
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters.
@ -236,6 +232,20 @@ func (c *BaseChannel) HandleMessage(
media []string,
metadata map[string]string,
senderOpts ...bus.SenderInfo,
) {
c.HandleMessageWithChannelName(ctx, peer, messageID, senderID, chatID, content, media, "", metadata, senderOpts...)
}
// HandleMessageWithChannelName handles an incoming message with an optional human-readable channel name.
// The channelName parameter is used for platforms like Discord where the channel has a user-friendly name.
func (c *BaseChannel) HandleMessageWithChannelName(
ctx context.Context,
peer bus.Peer,
messageID, senderID, chatID, content string,
media []string,
channelName string,
metadata map[string]string,
senderOpts ...bus.SenderInfo,
) {
// Use SenderInfo-based allow check when available, else fall back to string
var sender bus.SenderInfo
@ -261,16 +271,17 @@ func (c *BaseChannel) HandleMessage(
scope := BuildMediaScope(c.name, chatID, messageID)
msg := bus.InboundMessage{
Channel: c.name,
SenderID: resolvedSenderID,
Sender: sender,
ChatID: chatID,
Content: content,
Media: media,
Peer: peer,
MessageID: messageID,
MediaScope: scope,
Metadata: metadata,
Channel: c.name,
ChannelName: channelName,
SenderID: resolvedSenderID,
Sender: sender,
ChatID: chatID,
Content: content,
Media: media,
Peer: peer,
MessageID: messageID,
MediaScope: scope,
Metadata: metadata,
}
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
@ -288,15 +299,10 @@ func (c *BaseChannel) HandleMessage(
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
}
}
// Placeholder — independent pipeline.
// Skip when the message contains audio: the agent will send the
// placeholder after transcription completes, so the user sees
// "Thinking…" only once the voice has been processed.
if !audioAnnotationRe.MatchString(content) {
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
}
// Placeholder — independent pipeline
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
}
}
}

View file

@ -45,14 +45,6 @@ type DiscordChannel struct {
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
discordgo.LogWarning: logger.WARN,
discordgo.LogInformational: logger.INFO,
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
@ -142,7 +134,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil
}
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
return c.sendChunk(ctx, channelID, msg.Content)
}
// SendMedia implements the channels.MediaSender interface.
@ -267,29 +259,14 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
// Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
var err error
// If we have an ID, we send the message as "Reply"
if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content,
Reference: &discordgo.MessageReference{
MessageID: replyToID,
ChannelID: channelID,
},
})
} else {
// Otherwise, we send a normal message
_, err = c.session.ChannelMessageSend(channelID, content)
}
_, err := c.session.ChannelMessageSend(channelID, content)
done <- err
}()
@ -460,7 +437,17 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
}
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
// Get channel name for context (not available in DMs)
channelName := ""
if m.GuildID != "" {
if ch, err := s.State.Channel(m.ChannelID); err == nil && ch != nil {
channelName = ch.Name
} else if ch, err := c.session.Channel(m.ChannelID); err == nil && ch != nil {
channelName = ch.Name
}
}
c.HandleMessageWithChannelName(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, channelName, metadata, sender)
}
// startTyping starts a continuous typing indicator loop for the given chatID.