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:
parent
19835b2f60
commit
afbdc1b455
6 changed files with 205 additions and 154 deletions
|
|
@ -12,11 +12,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
|
|
@ -82,10 +80,8 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
toolDiscovery := cb.getDiscoveryRule()
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
version := config.FormatVersion()
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(`# picoclaw 🦞
|
||||||
`# picoclaw 🦞 (%s)
|
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
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.
|
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`,
|
%s`,
|
||||||
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getDiscoveryRule() string {
|
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://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
||||||
// See: https://platform.openai.com/docs/guides/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)")
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||||
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
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)
|
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
|
||||||
|
|
||||||
if channel != "" && chatID != "" {
|
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()
|
return sb.String()
|
||||||
|
|
@ -477,7 +477,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
summary string,
|
summary string,
|
||||||
currentMessage string,
|
currentMessage string,
|
||||||
media []string,
|
media []string,
|
||||||
channel, chatID string,
|
channel, channelName, chatID string,
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
|
|
@ -493,7 +493,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Build short dynamic context (time, runtime, session) — changes per request
|
// 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.
|
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||||
// Keeping all system content in one message ensures every provider adapter can
|
// 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)
|
// 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",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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
|
systemCount := 0
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
|
|
@ -576,7 +576,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also exercise BuildMessages concurrently
|
// Also exercise BuildMessages concurrently
|
||||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "", "chat")
|
||||||
if len(msgs) < 2 {
|
if len(msgs) < 2 {
|
||||||
errs <- "BuildMessages returned fewer than 2 messages"
|
errs <- "BuildMessages returned fewer than 2 messages"
|
||||||
return
|
return
|
||||||
|
|
@ -664,6 +664,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
|
@ -47,13 +48,13 @@ type AgentLoop struct {
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
transcriber voice.Transcriber
|
transcriber voice.Transcriber
|
||||||
cmdRegistry *commands.Registry
|
cmdRegistry *commands.Registry
|
||||||
mcp mcpRuntime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
type processOptions struct {
|
type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
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
|
ChatID string // Target chat ID for tool execution
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
Media []string // media:// refs from inbound message
|
Media []string // media:// refs from inbound message
|
||||||
|
|
@ -224,6 +225,13 @@ func registerSharedTools(
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
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)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
|
|
@ -239,8 +247,119 @@ func registerSharedTools(
|
||||||
|
|
||||||
func (al *AgentLoop) Run(ctx context.Context) error {
|
func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
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() {
|
for al.running.Load() {
|
||||||
|
|
@ -320,17 +439,6 @@ func (al *AgentLoop) Stop() {
|
||||||
|
|
||||||
// Close releases resources held by agent session stores. Call after Stop.
|
// Close releases resources held by agent session stores. Call after Stop.
|
||||||
func (al *AgentLoop) Close() {
|
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()
|
al.registry.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,10 +475,9 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
||||||
// replaces audio annotations in msg.Content with the transcribed text.
|
// 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 {
|
||||||
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
|
|
||||||
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
||||||
return msg, false
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transcribe each audio media ref in order.
|
// 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 {
|
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.
|
// Replace audio annotations sequentially with transcriptions.
|
||||||
idx := 0
|
idx := 0
|
||||||
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
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
|
msg.Content = newContent
|
||||||
return msg, true
|
return msg
|
||||||
}
|
|
||||||
|
|
||||||
// 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()})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
|
|
@ -519,10 +583,6 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
content, sessionKey, channel, chatID string,
|
content, sessionKey, channel, chatID string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
SenderID: "cron",
|
SenderID: "cron",
|
||||||
|
|
@ -575,14 +635,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
var hadAudio bool
|
msg = al.transcribeAudioInMessage(ctx, msg)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route system messages to processSystemMessage
|
// Route system messages to processSystemMessage
|
||||||
if msg.Channel == "system" {
|
if msg.Channel == "system" {
|
||||||
|
|
@ -618,6 +671,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
opts := processOptions{
|
opts := processOptions{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
|
ChannelName: msg.ChannelName,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
Media: msg.Media,
|
Media: msg.Media,
|
||||||
|
|
@ -761,6 +815,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.UserMessage,
|
opts.UserMessage,
|
||||||
opts.Media,
|
opts.Media,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
|
opts.ChannelName,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1029,7 +1084,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
messages = agent.ContextBuilder.BuildMessages(
|
messages = agent.ContextBuilder.BuildMessages(
|
||||||
newHistory, newSummary, "",
|
newHistory, newSummary, "",
|
||||||
nil, opts.Channel, opts.ChatID,
|
nil, opts.Channel, opts.ChannelName, opts.ChatID,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,24 +16,24 @@ type SenderInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
type InboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
SenderID string `json:"sender_id"`
|
ChannelName string `json:"channel_name,omitempty"` // human-readable channel name (e.g., Discord channel name)
|
||||||
Sender SenderInfo `json:"sender"`
|
SenderID string `json:"sender_id"`
|
||||||
ChatID string `json:"chat_id"`
|
Sender SenderInfo `json:"sender"`
|
||||||
Content string `json:"content"`
|
ChatID string `json:"chat_id"`
|
||||||
Media []string `json:"media,omitempty"`
|
Content string `json:"content"`
|
||||||
Peer Peer `json:"peer"` // routing peer
|
Media []string `json:"media,omitempty"`
|
||||||
MessageID string `json:"message_id,omitempty"` // platform message ID
|
Peer Peer `json:"peer"` // routing peer
|
||||||
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
SessionKey string `json:"session_key"`
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
SessionKey string `json:"session_key"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaPart describes a single media attachment to send.
|
// MediaPart describes a single media attachment to send.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -33,9 +32,6 @@ func init() {
|
||||||
uniqueIDPrefix = hex.EncodeToString(b[:])
|
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.
|
// 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
|
// 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.
|
// cryptographically secure — it must not be used in contexts where unpredictability matters.
|
||||||
|
|
@ -236,6 +232,20 @@ func (c *BaseChannel) HandleMessage(
|
||||||
media []string,
|
media []string,
|
||||||
metadata map[string]string,
|
metadata map[string]string,
|
||||||
senderOpts ...bus.SenderInfo,
|
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
|
// Use SenderInfo-based allow check when available, else fall back to string
|
||||||
var sender bus.SenderInfo
|
var sender bus.SenderInfo
|
||||||
|
|
@ -261,16 +271,17 @@ func (c *BaseChannel) HandleMessage(
|
||||||
scope := BuildMediaScope(c.name, chatID, messageID)
|
scope := BuildMediaScope(c.name, chatID, messageID)
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: c.name,
|
Channel: c.name,
|
||||||
SenderID: resolvedSenderID,
|
ChannelName: channelName,
|
||||||
Sender: sender,
|
SenderID: resolvedSenderID,
|
||||||
ChatID: chatID,
|
Sender: sender,
|
||||||
Content: content,
|
ChatID: chatID,
|
||||||
Media: media,
|
Content: content,
|
||||||
Peer: peer,
|
Media: media,
|
||||||
MessageID: messageID,
|
Peer: peer,
|
||||||
MediaScope: scope,
|
MessageID: messageID,
|
||||||
Metadata: metadata,
|
MediaScope: scope,
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
|
// 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)
|
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Placeholder — independent pipeline.
|
// Placeholder — independent pipeline
|
||||||
// Skip when the message contains audio: the agent will send the
|
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
||||||
// placeholder after transcription completes, so the user sees
|
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
|
||||||
// "Thinking…" only once the voice has been processed.
|
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,6 @@ type DiscordChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
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)
|
session, err := discordgo.New("Bot " + cfg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
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 nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
|
return c.sendChunk(ctx, channelID, msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
|
@ -267,29 +259,14 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
|
||||||
return msg.ID, nil
|
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
|
// Use the passed ctx for timeout control
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
_, err := c.session.ChannelMessageSend(channelID, content)
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
done <- err
|
done <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -460,7 +437,17 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
"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.
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue