feat(channels): support multi-message sending via split marker (#2008)

* Add multi-message sending via split marker

* Add marker and length split integration tests

Tests that SplitByMarker and SplitMessage work together correctly, and
that code block boundaries are preserved during marker splitting.

* Simplify message chunking logic in channel worker

Extract splitByLength helper function and remove goto-based control
flow.
The logic now flows more naturally - try marker splitting first, then
fall
back to length-based splitting.

* Update multi-message output instructions in agent context

* Add split_on_marker to config defaults

* Add split_on_marker config option

* Rename 'Multi-Message Sending' setting to 'Chatty Mode'

* Add SplitOnMarker config option
This commit is contained in:
柚子 2026-03-26 01:33:49 +08:00 committed by GitHub
parent 82c78e853b
commit ed618e14aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 264 additions and 44 deletions

View file

@ -10,6 +10,7 @@
"max_tool_iterations": 20,
"summarize_message_threshold": 20,
"summarize_token_percent": 75,
"split_on_marker": false,
"tool_feedback": {
"enabled": false,
"max_args_length": 300
@ -223,13 +224,8 @@
"nickserv_password": "",
"sasl_user": "",
"sasl_password": "",
"channels": [
"#mychannel"
],
"request_caps": [
"server-time",
"message-tags"
],
"channels": ["#mychannel"],
"request_caps": ["server-time", "message-tags"],
"allow_from": [],
"group_trigger": {
"mention_only": true
@ -251,9 +247,7 @@
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"api_keys": [
"YOUR_BRAVE_API_KEY"
],
"api_keys": ["YOUR_BRAVE_API_KEY"],
"max_results": 5
},
"tavily": {
@ -269,9 +263,7 @@
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
"api_keys": [
"pplx-xxx"
],
"api_keys": ["pplx-xxx"],
"max_results": 5
},
"searxng": {
@ -320,19 +312,12 @@
"filesystem": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
},
"github": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
@ -340,10 +325,7 @@
"brave-search": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
],
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
}
@ -360,10 +342,7 @@
"slack": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"

View file

@ -26,6 +26,7 @@ type ContextBuilder struct {
memory *MemoryStore
toolDiscoveryBM25 bool
toolDiscoveryRegex bool
splitOnMarker bool
// Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context.
@ -52,6 +53,11 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
return cb
}
func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
cb.splitOnMarker = enabled
return cb
}
func getGlobalConfigDir() string {
if home := os.Getenv(config.EnvHome); home != "" {
return home
@ -157,6 +163,14 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
parts = append(parts, "# Memory\n\n"+memoryContext)
}
// Multi-Message Sending (if enabled)
if cb.splitOnMarker {
parts = append(parts, `# MULTI-MESSAGE OUTPUT
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
Each part separated by the marker will be sent as an independent message.`)
}
// Join with "---" separator
return strings.Join(parts, "\n\n---\n\n")
}

View file

@ -103,10 +103,12 @@ func NewAgentInstance(
sessions := initSessionStore(sessionsDir)
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
)
contextBuilder := NewContextBuilder(workspace).
WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
).
WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker)
agentID := routing.DefaultAgentID
agentName := ""

View file

@ -608,8 +608,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker {
}
}
// runWorker processes outbound messages for a single channel, splitting
// messages that exceed the channel's maximum message length.
// runWorker processes outbound messages for a single channel.
// Message processing follows this order:
// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting
// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength)
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.done)
for {
@ -622,15 +624,29 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
chunks := SplitMessage(msg.Content, maxLen)
for _, chunk := range chunks {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, name, w, chunkMsg)
// Collect all message chunks to send
var chunks []string
// Step 1: Try marker-based splitting if enabled
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
for _, chunk := range markerChunks {
chunks = append(chunks, splitByLength(chunk, maxLen)...)
}
}
} else {
m.sendWithRetry(ctx, name, w, msg)
}
// Step 2: Fallback to length-based splitting if no chunks from marker
if len(chunks) == 0 {
chunks = splitByLength(msg.Content, maxLen)
}
// Step 3: Send all chunks
for _, chunk := range chunks {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, name, w, chunkMsg)
}
case <-ctx.Done():
return
@ -638,6 +654,14 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
}
}
// splitByLength splits content by maxLen if needed, otherwise returns single chunk.
func splitByLength(content string, maxLen int) []string {
if maxLen > 0 && len([]rune(content)) > maxLen {
return SplitMessage(content, maxLen)
}
return []string{content}
}
// sendWithRetry sends a message through the channel with rate limiting and
// retry logic. It classifies errors to determine the retry strategy:
// - ErrNotRunning / ErrSendFailed: permanent, no retry

37
pkg/channels/marker.go Normal file
View file

@ -0,0 +1,37 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package channels
import (
"strings"
)
// MessageSplitMarker is the delimiter used to split a message into multiple outbound messages.
// When SplitOnMarker is enabled in config, the Manager will split messages on this marker
// and send each part as a separate message.
const MessageSplitMarker = "<|[SPLIT]|>"
// SplitByMarker splits a message by the MessageSplitMarker and returns the parts.
// Empty parts (including from consecutive markers) are filtered out.
// If no marker is found, returns a single-element slice containing the original content.
func SplitByMarker(content string) []string {
if content == "" {
return nil
}
parts := strings.Split(content, MessageSplitMarker)
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
if len(result) == 0 {
return []string{content}
}
return result
}

141
pkg/channels/marker_test.go Normal file
View file

@ -0,0 +1,141 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package channels
import (
"testing"
)
func TestSplitByMarker_Basic(t *testing.T) {
content := "Hello <|[SPLIT]|>World"
chunks := SplitByMarker(content)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
if chunks[0] != "Hello" {
t.Errorf("Expected first chunk 'Hello', got %q", chunks[0])
}
if chunks[1] != "World" {
t.Errorf("Expected second chunk 'World', got %q", chunks[1])
}
}
func TestSplitByMarker_NoMarker(t *testing.T) {
content := "Hello World"
chunks := SplitByMarker(content)
if len(chunks) != 1 {
t.Fatalf("Expected 1 chunk, got %d: %q", len(chunks), chunks)
}
if chunks[0] != "Hello World" {
t.Errorf("Expected chunk 'Hello World', got %q", chunks[0])
}
}
func TestSplitByMarker_MultipleMarkers(t *testing.T) {
content := "Part1 <|[SPLIT]|> Part2 <|[SPLIT]|> Part3"
chunks := SplitByMarker(content)
if len(chunks) != 3 {
t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks)
}
if chunks[0] != "Part1" || chunks[1] != "Part2" || chunks[2] != "Part3" {
t.Errorf("Unexpected chunks: %q", chunks)
}
}
func TestSplitByMarker_EmptyParts(t *testing.T) {
// Test consecutive markers and leading/trailing markers
content := "<|[SPLIT]|>Hello <|[SPLIT]|><|[SPLIT]|>World<|[SPLIT]|>"
chunks := SplitByMarker(content)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
if chunks[0] != "Hello" || chunks[1] != "World" {
t.Errorf("Unexpected chunks: %q", chunks)
}
}
func TestSplitByMarker_WhitespaceTrimmed(t *testing.T) {
content := " Hello <|[SPLIT]|> World "
chunks := SplitByMarker(content)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
if chunks[0] != "Hello" || chunks[1] != "World" {
t.Errorf("Whitespace should be trimmed: %q", chunks)
}
}
func TestSplitByMarker_EmptyInput(t *testing.T) {
chunks := SplitByMarker("")
if len(chunks) != 0 {
t.Errorf("Expected empty slice for empty input, got %d chunks", len(chunks))
}
}
// TestMarkerAndLengthSplitIntegration tests that SplitByMarker and SplitMessage work together correctly.
// Marker splitting happens first (per-agent config), then length splitting happens (per-channel config).
func TestMarkerAndLengthSplitIntegration(t *testing.T) {
maxLen := 10
// Original content: "Short <|[SPLIT]|> ThisIsAVeryLongString"
content := "Short <|[SPLIT]|> ThisIsAVeryLongString"
markerChunks := SplitByMarker(content)
// Step 1: Marker split should give us 2 chunks
if len(markerChunks) != 2 {
t.Fatalf("Expected 2 marker chunks, got %d: %q", len(markerChunks), markerChunks)
}
// Step 2: Length split should be applied to each marker chunk
var finalChunks []string
for _, chunk := range markerChunks {
if len([]rune(chunk)) > maxLen {
lengthChunks := SplitMessage(chunk, maxLen)
finalChunks = append(finalChunks, lengthChunks...)
} else {
finalChunks = append(finalChunks, chunk)
}
}
// "Short" is 6 chars, within limit
// "ThisIsAVeryLongString" is 22 chars, should be split into multiple chunks
// SplitMessage with maxLen=10 splits: "ThisIsAVeryLongString" -> ["ThisI", "sAVer", "yLong", "String"] (5 chunks)
if len(finalChunks) != 5 {
t.Errorf("Expected 5 final chunks, got %d: %q", len(finalChunks), finalChunks)
}
// Verify first chunk is unchanged
if finalChunks[0] != "Short" {
t.Errorf("First chunk should be 'Short', got %q", finalChunks[0])
}
// Verify all length-split chunks are within limit
for i, chunk := range finalChunks[1:] {
if len([]rune(chunk)) > maxLen {
t.Errorf("Chunk %d exceeds maxLen: %q (%d chars)", i+1, chunk, len([]rune(chunk)))
}
}
}
// TestMarkerSplitPreservesCodeBlockIntegrity tests that marker split preserves code block boundaries
func TestMarkerSplitPreservesCodeBlockIntegrity(t *testing.T) {
content := "Hello <|[SPLIT]|>```go\npackage main\n```<|[SPLIT]|>World"
chunks := SplitByMarker(content)
if len(chunks) != 3 {
t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks)
}
// Verify code block is intact in middle chunk
if chunks[1] != "```go\npackage main\n```" {
t.Errorf("Code block not preserved correctly: %q", chunks[1])
}
}

View file

@ -319,6 +319,7 @@ type AgentDefaults struct {
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB

View file

@ -42,6 +42,7 @@ func DefaultConfig() *Config {
Enabled: true,
MaxArgsLength: 300,
},
SplitOnMarker: false,
},
},
Bindings: []AgentBinding{},

View file

@ -208,6 +208,7 @@ export function ConfigPage() {
defaults: {
workspace,
restrict_to_workspace: form.restrictToWorkspace,
split_on_marker: form.splitOnMarker,
tool_feedback: {
enabled: form.toolFeedbackEnabled,
max_args_length: toolFeedbackMaxArgsLength,

View file

@ -95,6 +95,16 @@ export function AgentDefaultsSection({
}
/>
<SwitchCardField
label={t("pages.config.split_on_marker")}
hint={t("pages.config.split_on_marker_hint")}
layout="setting-row"
checked={form.splitOnMarker}
onCheckedChange={(checked) =>
onFieldChange("splitOnMarker", checked)
}
/>
<SwitchCardField
label={t("pages.config.tool_feedback_enabled")}
hint={t("pages.config.tool_feedback_enabled_hint")}

View file

@ -3,6 +3,7 @@ export type JsonRecord = Record<string, unknown>
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
splitOnMarker: boolean
toolFeedbackEnabled: boolean
toolFeedbackMaxArgsLength: string
execEnabled: boolean
@ -65,6 +66,7 @@ export const DM_SCOPE_OPTIONS = [
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
splitOnMarker: false,
toolFeedbackEnabled: true,
toolFeedbackMaxArgsLength: "300",
execEnabled: true,
@ -136,6 +138,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
splitOnMarker:
defaults.split_on_marker === undefined
? EMPTY_FORM.splitOnMarker
: asBool(defaults.split_on_marker),
toolFeedbackEnabled:
toolFeedback.enabled === undefined
? EMPTY_FORM.toolFeedbackEnabled

View file

@ -434,6 +434,8 @@
"workspace_hint": "Base directory for agent file operations.",
"restrict_workspace": "Restrict to Workspace",
"restrict_workspace_hint": "Only allow file operations inside workspace.",
"split_on_marker": "Chatty Mode",
"split_on_marker_hint": "Split long messages into short ones like real human chatting.",
"tool_feedback_enabled": "Tool Feedback",
"tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.",
"tool_feedback_max_args_length": "Tool Feedback Args Preview Length",

View file

@ -434,6 +434,8 @@
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
"restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
"split_on_marker": "连续短消息",
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
"tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。",
"tool_feedback_max_args_length": "工具反馈参数预览长度",