Add multi-message sending via split marker

This commit is contained in:
uiyzzi 2026-03-25 22:10:23 +08:00
parent e4f4afcd4d
commit ec36369c88
10 changed files with 213 additions and 13 deletions

View file

@ -26,6 +26,7 @@ type ContextBuilder struct {
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool toolDiscoveryBM25 bool
toolDiscoveryRegex bool toolDiscoveryRegex bool
splitOnMarker bool
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -52,6 +53,11 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
return cb return cb
} }
func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
cb.splitOnMarker = enabled
return cb
}
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
if home := os.Getenv(config.EnvHome); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return home return home
@ -157,6 +163,17 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
parts = append(parts, "# Memory\n\n"+memoryContext) parts = append(parts, "# Memory\n\n"+memoryContext)
} }
// Multi-Message Sending (if enabled)
if cb.splitOnMarker {
parts = append(parts, `# Multi-Message Sending
When you want to send multiple separate messages in a single response, you can use the special marker "<|[SPLIT]|>" to separate them. For 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. Use this to break up long responses or when you want to send multiple distinct pieces of information.`)
}
// Join with "---" separator // Join with "---" separator
return strings.Join(parts, "\n\n---\n\n") return strings.Join(parts, "\n\n---\n\n")
} }

View file

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

View file

@ -608,8 +608,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker {
} }
} }
// runWorker processes outbound messages for a single channel, splitting // runWorker processes outbound messages for a single channel.
// messages that exceed the channel's maximum message length. // 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) { func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.done) defer close(w.done)
for { for {
@ -622,15 +624,55 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
if mlp, ok := w.ch.(MessageLengthProvider); ok { if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength() maxLen = mlp.MaxMessageLength()
} }
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
chunks := SplitMessage(msg.Content, maxLen) // Collect all message chunks to send
for _, chunk := range chunks { var chunks []string
chunkMsg := msg
chunkMsg.Content = chunk // Step 1: Split by marker if enabled and marker is present
m.sendWithRetry(ctx, name, w, chunkMsg) splitOnMarker := false
if m.config != nil {
splitOnMarker = m.config.Agents.Defaults.SplitOnMarker
}
if splitOnMarker {
markerChunks := SplitByMarker(msg.Content)
if len(markerChunks) > 1 {
// Marker found, process each chunk
for _, chunk := range markerChunks {
// Step 2: Further split by length if needed
if maxLen > 0 && len([]rune(chunk)) > maxLen {
subChunks := SplitMessage(chunk, maxLen)
chunks = append(chunks, subChunks...)
} else {
chunks = append(chunks, chunk)
}
}
} else {
// No marker found, fall through to length-based splitting
goto lengthSplit
} }
} else { } else {
m.sendWithRetry(ctx, name, w, msg) // Marker disabled, use length-based splitting
goto lengthSplit
}
lengthSplit:
// Length-based splitting (also used as fallback when no marker found)
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
chunks = SplitMessage(msg.Content, maxLen)
} else if len(chunks) == 0 {
chunks = []string{msg.Content}
}
// Fallback: if no chunks collected, use original message
if len(chunks) == 0 {
chunks = []string{msg.Content}
}
// Step 3: Send all chunks
for _, chunk := range chunks {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, name, w, chunkMsg)
} }
case <-ctx.Done(): case <-ctx.Done():
return return

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
}

View file

@ -0,0 +1,81 @@
// 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))
}
}

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" 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_"` SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` 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 const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB

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 <SwitchCardField
label={t("pages.config.tool_feedback_enabled")} label={t("pages.config.tool_feedback_enabled")}
hint={t("pages.config.tool_feedback_enabled_hint")} hint={t("pages.config.tool_feedback_enabled_hint")}

View file

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

View file

@ -434,6 +434,8 @@
"workspace_hint": "Base directory for agent file operations.", "workspace_hint": "Base directory for agent file operations.",
"restrict_workspace": "Restrict to Workspace", "restrict_workspace": "Restrict to Workspace",
"restrict_workspace_hint": "Only allow file operations inside workspace.", "restrict_workspace_hint": "Only allow file operations inside workspace.",
"split_on_marker": "Multi-Message Sending",
"split_on_marker_hint": "Allow sending multiple messages in a single response.",
"tool_feedback_enabled": "Tool Feedback", "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_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", "tool_feedback_max_args_length": "Tool Feedback Args Preview Length",

View file

@ -434,6 +434,8 @@
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。", "workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
"restrict_workspace": "限制工作目录访问", "restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
"split_on_marker": "多消息发送",
"split_on_marker_hint": "开启后,可以在一次回复中发送多条消息。",
"tool_feedback_enabled": "工具反馈", "tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。", "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。",
"tool_feedback_max_args_length": "工具反馈参数预览长度", "tool_feedback_max_args_length": "工具反馈参数预览长度",