diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 12e3cdd4d..ae8296430 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -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,17 @@ 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 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 return strings.Join(parts, "\n\n---\n\n") } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 34d401186..cef736981 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -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 := "" diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c8269dc77..a8311dd36 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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,55 @@ 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: Split by marker if enabled and marker is present + 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 { - 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(): return diff --git a/pkg/channels/marker.go b/pkg/channels/marker.go new file mode 100644 index 000000000..4801e3d27 --- /dev/null +++ b/pkg/channels/marker.go @@ -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 +} diff --git a/pkg/channels/marker_test.go b/pkg/channels/marker_test.go new file mode 100644 index 000000000..d3719d428 --- /dev/null +++ b/pkg/channels/marker_test.go @@ -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)) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c61219d9b..fd7c48964 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 1f7426d22..b5bec8acd 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -95,6 +95,16 @@ export function AgentDefaultsSection({ } /> + + onFieldChange("splitOnMarker", checked) + } + /> + 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 diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 8e051f42e..f81024c34 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -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": "Multi-Message Sending", + "split_on_marker_hint": "Allow sending multiple messages in a single response.", "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", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index a6c588807..2199cab7b 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -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": "工具反馈参数预览长度",