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,
|
"max_tool_iterations": 20,
|
||||||
"summarize_message_threshold": 20,
|
"summarize_message_threshold": 20,
|
||||||
"summarize_token_percent": 75,
|
"summarize_token_percent": 75,
|
||||||
|
"split_on_marker": false,
|
||||||
"tool_feedback": {
|
"tool_feedback": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"max_args_length": 300
|
"max_args_length": 300
|
||||||
|
|
@ -223,13 +224,8 @@
|
||||||
"nickserv_password": "",
|
"nickserv_password": "",
|
||||||
"sasl_user": "",
|
"sasl_user": "",
|
||||||
"sasl_password": "",
|
"sasl_password": "",
|
||||||
"channels": [
|
"channels": ["#mychannel"],
|
||||||
"#mychannel"
|
"request_caps": ["server-time", "message-tags"],
|
||||||
],
|
|
||||||
"request_caps": [
|
|
||||||
"server-time",
|
|
||||||
"message-tags"
|
|
||||||
],
|
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -251,9 +247,7 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"api_keys": [
|
"api_keys": ["YOUR_BRAVE_API_KEY"],
|
||||||
"YOUR_BRAVE_API_KEY"
|
|
||||||
],
|
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -269,9 +263,7 @@
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
"api_keys": [
|
"api_keys": ["pplx-xxx"],
|
||||||
"pplx-xxx"
|
|
||||||
],
|
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
@ -320,19 +312,12 @@
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-filesystem",
|
|
||||||
"/tmp"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-github"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
}
|
}
|
||||||
|
|
@ -340,10 +325,7 @@
|
||||||
"brave-search": {
|
"brave-search": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-brave-search"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||||
}
|
}
|
||||||
|
|
@ -360,10 +342,7 @@
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-slack"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
|
|
||||||
|
|
@ -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,14 @@ 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 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
|
// Join with "---" separator
|
||||||
return strings.Join(parts, "\n\n---\n\n")
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 := ""
|
||||||
|
|
|
||||||
|
|
@ -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,29 @@ 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: Try marker-based splitting if enabled
|
||||||
m.sendWithRetry(ctx, name, w, chunkMsg)
|
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():
|
case <-ctx.Done():
|
||||||
return
|
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
|
// sendWithRetry sends a message through the channel with rate limiting and
|
||||||
// retry logic. It classifies errors to determine the retry strategy:
|
// retry logic. It classifies errors to determine the retry strategy:
|
||||||
// - ErrNotRunning / ErrSendFailed: permanent, no retry
|
// - 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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ func DefaultConfig() *Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxArgsLength: 300,
|
MaxArgsLength: 300,
|
||||||
},
|
},
|
||||||
|
SplitOnMarker: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ export function ConfigPage() {
|
||||||
defaults: {
|
defaults: {
|
||||||
workspace,
|
workspace,
|
||||||
restrict_to_workspace: form.restrictToWorkspace,
|
restrict_to_workspace: form.restrictToWorkspace,
|
||||||
|
split_on_marker: form.splitOnMarker,
|
||||||
tool_feedback: {
|
tool_feedback: {
|
||||||
enabled: form.toolFeedbackEnabled,
|
enabled: form.toolFeedbackEnabled,
|
||||||
max_args_length: toolFeedbackMaxArgsLength,
|
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
|
<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")}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": "Chatty Mode",
|
||||||
|
"split_on_marker_hint": "Split long messages into short ones like real human chatting.",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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": "工具反馈参数预览长度",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue