Add multi-message sending via split marker
This commit is contained in:
parent
e4f4afcd4d
commit
ec36369c88
10 changed files with 213 additions and 13 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
|
|
|
|||
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
|
||||
}
|
||||
81
pkg/channels/marker_test.go
Normal file
81
pkg/channels/marker_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "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",
|
||||
|
|
|
|||
|
|
@ -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