diff --git a/config/config.example.json b/config/config.example.json index 77a8c0683..82a419319 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -50,6 +50,7 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", + "reasoning_channel_id": "-1001234567890", "allow_from": [ "YOUR_USER_ID" ] @@ -57,6 +58,7 @@ "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", + "reasoning_channel_id": "123456789012345678", "allow_from": [], "mention_only": false }, @@ -64,17 +66,20 @@ "enabled": false, "app_id": "YOUR_QQ_APP_ID", "app_secret": "YOUR_QQ_APP_SECRET", + "reasoning_channel_id": "group-123456", "allow_from": [] }, "maixcam": { "enabled": false, "host": "0.0.0.0", "port": 18790, + "reasoning_channel_id": "maixcam-room-1", "allow_from": [] }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", + "reasoning_channel_id": "12025550123@c.us", "allow_from": [] }, "feishu": { @@ -83,18 +88,21 @@ "app_secret": "", "encrypt_key": "", "verification_token": "", + "reasoning_channel_id": "oc_reasoning_chat_id", "allow_from": [] }, "dingtalk": { "enabled": false, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", + "reasoning_channel_id": "cid_reasoning_group", "allow_from": [] }, "slack": { "enabled": false, "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", + "reasoning_channel_id": "C0123456789", "allow_from": [] }, "line": { @@ -104,6 +112,7 @@ "webhook_host": "0.0.0.0", "webhook_port": 18791, "webhook_path": "/webhook/line", + "reasoning_channel_id": "U0123456789abcdef0123456789abcd", "allow_from": [] }, "onebot": { @@ -112,6 +121,7 @@ "access_token": "", "reconnect_interval": 5, "group_trigger_prefix": [], + "reasoning_channel_id": "123456789", "allow_from": [] }, "wecom": { @@ -123,6 +133,7 @@ "webhook_host": "0.0.0.0", "webhook_port": 18793, "webhook_path": "/webhook/wecom", + "reasoning_channel_id": "wecom-group-001", "allow_from": [], "reply_timeout": 5 }, @@ -137,6 +148,7 @@ "webhook_host": "0.0.0.0", "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", + "reasoning_channel_id": "wecom-user-001", "allow_from": [], "reply_timeout": 5 } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b36f4a0c4..4d3c21b51 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -79,6 +79,25 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } } +const telegramMaxMessageLength = 4096 + + +func chunkString(s string, size int) []string { + if size <= 0 || s == "" { + return nil + } + + runes := []rune(s) + chunks := make([]string, 0, (len(runes)+size-1)/size) + + for i := 0; i < len(runes); i += size { + end := min(i + size, len(runes)) + chunks = append(chunks, string(runes[i:end])) + } + + return chunks +} + // registerSharedTools registers tools that are shared across all agents (web, message, spawn). func registerSharedTools( cfg *config.Config, @@ -466,6 +485,58 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt return finalContent, nil } +func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { + var channels = al.cfg.Channels + + switch channelName { + case "telegram": + return channels.Telegram.ReasoningChannelID + case "whatsapp": + return channels.WhatsApp.ReasoningChannelID + case "feishu": + return channels.Feishu.ReasoningChannelID + case "discord": + return channels.Discord.ReasoningChannelID + case "maixcam": + return channels.MaixCam.ReasoningChannelID + case "qq": + return channels.QQ.ReasoningChannelID + case "dingtalk": + return channels.DingTalk.ReasoningChannelID + case "slack": + return channels.Slack.ReasoningChannelID + case "line": + return channels.LINE.ReasoningChannelID + case "onebot": + return channels.OneBot.ReasoningChannelID + case "wecom": + return channels.WeCom.ReasoningChannelID + case "wecom_app": + return channels.WeComApp.ReasoningChannelID + } + return +} + + +func (al *AgentLoop) handleReasoning(reasoningContent, channelName, channelID string) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + messages := []string{reasoningContent} + if channelName == "telegram" { + messages = chunkString(reasoningContent, telegramMaxMessageLength) + } + + for _, message := range messages { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: channelName, + ChatID: channelID, + Content: message, + }) + } +} + // runLLMIteration executes the LLM call loop with tool handling. func (al *AgentLoop) runLLMIteration( ctx context.Context, @@ -521,6 +592,7 @@ func (al *AgentLoop) runLLMIteration( return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, + "think": true, }) }, ) @@ -589,6 +661,17 @@ func (al *AgentLoop) runLLMIteration( }) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } + + go al.handleReasoning(response.Reasoning, opts.Channel , al.targetReasoningChannelID(opts.Channel)) + // Log LLM response details + logger.InfoCF("agent", "LLM response", + map[string]any{ + "agent_id": agent.ID, + "iteration": iteration, + "content_chars": len(response.Content), + "tool_calls": len(response.ToolCalls), + "reasoning": response.Reasoning, + }) // Check if no tool calls - we're done if len(response.ToolCalls) == 0 { diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..d05aaff3d 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" @@ -631,3 +632,184 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } + +func TestChunkString(t *testing.T) { + t.Run("returns nil for invalid size or empty input", func(t *testing.T) { + if got := chunkString("", 10); got != nil { + t.Fatalf("expected nil for empty input, got %v", got) + } + if got := chunkString("abc", 0); got != nil { + t.Fatalf("expected nil for size=0, got %v", got) + } + if got := chunkString("abc", -1); got != nil { + t.Fatalf("expected nil for size<0, got %v", got) + } + }) + + t.Run("chunks by rune count and preserves utf8 validity", func(t *testing.T) { + in := "ab😀cd界x" + got := chunkString(in, 3) + want := []string{"ab😀", "cd界", "x"} + if len(got) != len(want) { + t.Fatalf("chunk count mismatch: got %d want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("chunk[%d] mismatch: got %q want %q", i, got[i], want[i]) + } + if !utf8.ValidString(got[i]) { + t.Fatalf("chunk[%d] is not valid utf8: %q", i, got[i]) + } + } + }) +} + +func TestTargetReasoningChannelID_AllChannels(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Channels: config.ChannelsConfig{ + WhatsApp: config.WhatsAppConfig{ReasoningChannelID: "rid-whatsapp"}, + Telegram: config.TelegramConfig{ReasoningChannelID: "rid-telegram"}, + Feishu: config.FeishuConfig{ReasoningChannelID: "rid-feishu"}, + Discord: config.DiscordConfig{ReasoningChannelID: "rid-discord"}, + MaixCam: config.MaixCamConfig{ReasoningChannelID: "rid-maixcam"}, + QQ: config.QQConfig{ReasoningChannelID: "rid-qq"}, + DingTalk: config.DingTalkConfig{ReasoningChannelID: "rid-dingtalk"}, + Slack: config.SlackConfig{ReasoningChannelID: "rid-slack"}, + LINE: config.LINEConfig{ReasoningChannelID: "rid-line"}, + OneBot: config.OneBotConfig{ReasoningChannelID: "rid-onebot"}, + WeCom: config.WeComConfig{ReasoningChannelID: "rid-wecom"}, + WeComApp: config.WeComAppConfig{ReasoningChannelID: "rid-wecom-app"}, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + tests := []struct { + channel string + wantID string + }{ + {channel: "whatsapp", wantID: "rid-whatsapp"}, + {channel: "telegram", wantID: "rid-telegram"}, + {channel: "feishu", wantID: "rid-feishu"}, + {channel: "discord", wantID: "rid-discord"}, + {channel: "maixcam", wantID: "rid-maixcam"}, + {channel: "qq", wantID: "rid-qq"}, + {channel: "dingtalk", wantID: "rid-dingtalk"}, + {channel: "slack", wantID: "rid-slack"}, + {channel: "line", wantID: "rid-line"}, + {channel: "onebot", wantID: "rid-onebot"}, + {channel: "wecom", wantID: "rid-wecom"}, + {channel: "wecom_app", wantID: "rid-wecom-app"}, + {channel: "unknown", wantID: ""}, + } + + for _, tt := range tests { + t.Run(tt.channel, func(t *testing.T) { + got := al.targetReasoningChannelID(tt.channel) + if got != tt.wantID { + t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) + } + }) + } +} + +func TestHandleReasoning(t *testing.T) { + newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus + } + + t.Run("skips when any required field is empty", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning("reasoning", "telegram", "") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if msg, ok := msgBus.SubscribeOutbound(ctx); ok { + t.Fatalf("expected no outbound message, got %+v", msg) + } + }) + + t.Run("publishes one message for non telegram", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning("hello reasoning", "slack", "channel-1") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + msg, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected an outbound message") + } + if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { + t.Fatalf("unexpected outbound message: %+v", msg) + } + }) + + t.Run("chunks telegram messages", func(t *testing.T) { + al, msgBus := newLoop(t) + large := make([]rune, telegramMaxMessageLength+5) + for i := range large { + large[i] = '界' + } + largeReasoning := string(large) + al.handleReasoning(largeReasoning, "telegram", "tg-chat") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + msg1, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected first outbound message") + } + msg2, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected second outbound message") + } + + if msg1.Channel != "telegram" || msg2.Channel != "telegram" { + t.Fatalf("expected telegram channel messages, got %+v and %+v", msg1, msg2) + } + if msg1.ChatID != "tg-chat" || msg2.ChatID != "tg-chat" { + t.Fatalf("expected chatID tg-chat, got %+v and %+v", msg1, msg2) + } + + gotCombined := msg1.Content + msg2.Content + if gotCombined != largeReasoning { + t.Fatalf("chunked content mismatch: got len=%d want len=%d", len(gotCombined), len(largeReasoning)) + } + if len([]rune(msg1.Content)) != telegramMaxMessageLength { + t.Fatalf("first chunk rune length = %d, want %d", len([]rune(msg1.Content)), telegramMaxMessageLength) + } + if len([]rune(msg2.Content)) != 5 { + t.Fatalf("second chunk rune length = %d, want 5", len([]rune(msg2.Content))) + } + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 20556011a..f203d6c65 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -193,11 +193,11 @@ type ChannelsConfig struct { WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` } - type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } type TelegramConfig struct { @@ -205,6 +205,7 @@ type TelegramConfig struct { Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` } type FeishuConfig struct { @@ -214,6 +215,7 @@ type FeishuConfig struct { EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` } type DiscordConfig struct { @@ -221,6 +223,7 @@ type DiscordConfig struct { Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } type MaixCamConfig struct { @@ -228,6 +231,7 @@ type MaixCamConfig struct { Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"` } type QQConfig struct { @@ -235,6 +239,7 @@ type QQConfig struct { AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } type DingTalkConfig struct { @@ -242,6 +247,7 @@ type DingTalkConfig struct { ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } type SlackConfig struct { @@ -249,6 +255,7 @@ type SlackConfig struct { BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } type LINEConfig struct { @@ -259,6 +266,7 @@ type LINEConfig struct { WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` } type OneBotConfig struct { @@ -268,6 +276,7 @@ type OneBotConfig struct { ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` } type WeComConfig struct { @@ -280,6 +289,7 @@ type WeComConfig struct { WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"` } type WeComAppConfig struct { @@ -294,6 +304,7 @@ type WeComAppConfig struct { WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } type HeartbeatConfig struct { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index b8528953a..1bf3725e4 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -148,6 +148,13 @@ func parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { + Reasoning string `json:"reasoning"` + ReasoningDetails []struct { + Format string `json:"format"` + Index int `json:"index"` + Type string `json:"type"` + Text string `json:"text"` + } `json:"reasoning_details"` Content string `json:"content"` ToolCalls []struct { ID string `json:"id"` @@ -221,6 +228,8 @@ func parseResponse(body []byte) (*LLMResponse, error) { } return &LLMResponse{ + Reasoning: choice.Message.Reasoning, + ReasoningDetails: choice.Message.ReasoningDetails, Content: choice.Message.Content, ToolCalls: toolCalls, FinishReason: choice.FinishReason, diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 3a089ca47..8d8a08179 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -25,12 +25,18 @@ type FunctionCall struct { } type LLMResponse struct { - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` + Reasoning string `json:"reasoning"` + ReasoningDetails []struct { + Format string `json:"format"` + Index int `json:"index"` + Type string `json:"type"` + Text string `json:"text"` + } `json:"reasoning_details"` } - type UsageInfo struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"`