diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e22..18563fc7d9 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -458,7 +458,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, chatID, chatName 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 +466,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) + if chatName != "" { + fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s (%s)", channel, chatID, chatName) + } else { + fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) + } } return sb.String() @@ -477,7 +481,7 @@ func (cb *ContextBuilder) BuildMessages( summary string, currentMessage string, media []string, - channel, chatID string, + channel, chatID, chatName string, ) []providers.Message { messages := []providers.Message{} @@ -493,7 +497,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, chatID, chatName) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820d..4f9b8cb05f 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -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,22 @@ 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", "") + } +} + +func TestBuildMessages_IncludesChatNameInDynamicContext(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "1234567890", "#general") + if len(msgs) == 0 { + t.Fatal("expected messages") + } + if !strings.Contains(msgs[0].Content, "Chat ID: 1234567890 (#general)") { + t.Fatalf("dynamic context missing chat name: %q", msgs[0].Content) } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 28e549ce03..444e8c8550 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -55,6 +55,7 @@ type processOptions struct { SessionKey string // Session identifier for history/context Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution + ChatName string // Optional human-readable chat/channel name UserMessage string // User message content (may include prefix) Media []string // media:// refs from inbound message DefaultResponse string // Response when LLM returns empty @@ -67,6 +68,7 @@ const ( defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." sessionKeyAgentPrefix = "agent:" metadataKeyAccountID = "account_id" + metadataKeyChatName = "chat_name" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" metadataKeyParentPeerKind = "parent_peer_kind" @@ -619,6 +621,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, + ChatName: inboundMetadata(msg, metadataKeyChatName), UserMessage: msg.Content, Media: msg.Media, DefaultResponse: defaultResponse, @@ -762,6 +765,7 @@ func (al *AgentLoop) runAgentLoop( opts.Media, opts.Channel, opts.ChatID, + opts.ChatName, ) // Resolve media:// refs to base64 data URLs (streaming) @@ -1029,7 +1033,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.ChatID, opts.ChatName, ) continue } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index fbfcad1513..6f627a082c 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -451,10 +451,28 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + if channelName := c.resolveChannelName(m.ChannelID); channelName != "" { + metadata["chat_name"] = channelName + } c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } +func (c *DiscordChannel) resolveChannelName(channelID string) string { + if c == nil || c.session == nil || strings.TrimSpace(channelID) == "" { + return "" + } + if c.session.State != nil { + if ch, err := c.session.State.Channel(channelID); err == nil && ch != nil && strings.TrimSpace(ch.Name) != "" { + return strings.TrimSpace(ch.Name) + } + } + if ch, err := c.session.Channel(channelID); err == nil && ch != nil && strings.TrimSpace(ch.Name) != "" { + return strings.TrimSpace(ch.Name) + } + return "" +} + // startTyping starts a continuous typing indicator loop for the given chatID. // It stops any existing typing loop for that chatID before starting a new one. func (c *DiscordChannel) startTyping(chatID string) { diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f40..6fd4a18831 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -89,3 +89,26 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestResolveChannelName_FromState(t *testing.T) { + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.State = discordgo.NewState() + if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil { + t.Fatalf("GuildAdd() error: %v", err) + } + if err := session.State.ChannelAdd(&discordgo.Channel{ + ID: "123", + GuildID: "guild-1", + Name: "general", + }); err != nil { + t.Fatalf("ChannelAdd() error: %v", err) + } + + ch := &DiscordChannel{session: session} + if got := ch.resolveChannelName("123"); got != "general" { + t.Fatalf("resolveChannelName() = %q, want %q", got, "general") + } +}