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:
parent
82c78e853b
commit
ed618e14aa
13 changed files with 264 additions and 44 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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
37
pkg/channels/marker.go
Normal 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
141
pkg/channels/marker_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ func DefaultConfig() *Config {
|
|||
Enabled: true,
|
||||
MaxArgsLength: 300,
|
||||
},
|
||||
SplitOnMarker: false,
|
||||
},
|
||||
},
|
||||
Bindings: []AgentBinding{},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "工具反馈参数预览长度",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue