feat(agent): integrate DAG tools, map operators, obligations, and continuity config

pkg/agent/loop.go
- Register dag_expand, dag_describe, dag_grep, agentic_map, llm_map,
  map_run_status, map_run_cancel, and obligation_* tools in the agent loop
- Wire DAGStore and MapRuntime into the tool registry at startup
- Add projection pointer tracking: save pointer after each successful run,
  detect continuity breaks on session restore and log warnings
- Emergency compression path: when context exceeds token budget, trigger
  DAG compression and save a snapshot before retrying

pkg/agent/context.go
- ContextBuilder now uses ContinuityRetentionConfig to bound the number
  of recent messages retained unsummarized (min/max/target ratio/failure
  fallback)
- BuildSystemPrompt: add section for obligation awareness when due
  obligations exist in the current session

pkg/config/config.go + config_test.go
- Add ContinuityRetentionConfig struct with MinMessages, MaxMessages,
  TargetContextRatio, FailureKeepMessages fields
- AgentDefaults.ContinuityRetention field wires the new config into the
  agent context builder
- All env var names updated to DRAGONSCALE_* prefix

pkg/tools/subagent.go
- SubagentTask: add ParentTaskID, Depth, DelegatedScope, KeptWork fields
  for hierarchical delegation tracking
- delegationCtxKey context values propagate task ID and depth through
  the call chain; prevents runaway recursion via max-depth guard
- SubagentManager: add Cancel(), ListActive(), and GetTask() methods

pkg/tools/subagent_tool_test.go + spawn_test.go + subagent_manager_test.go
- Tests for delegation depth limiting, parent task ID propagation,
  cancel/list/get operations, and spawn tool integration

pkg/tools/toolloop_test.go
- Tests for ToolLoopConfig validation and result aggregation
This commit is contained in:
ZanzyTHEbar 2026-02-21 19:01:17 +00:00
parent 6ad5c112b2
commit 5ca4a8c6da
11 changed files with 1777 additions and 399 deletions

View file

@ -9,12 +9,13 @@ import (
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/ZanzyTHEbar/dragonscale/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/ZanzyTHEbar/dragonscale/pkg/logger"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/ZanzyTHEbar/dragonscale/pkg/memory"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/ZanzyTHEbar/dragonscale/pkg/memory/observation"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/ZanzyTHEbar/dragonscale/pkg/messages"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/ZanzyTHEbar/dragonscale/pkg/skills"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
) )
type ContextBuilder struct { type ContextBuilder struct {
@ -37,7 +38,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
primarySkillsDir = dir primarySkillsDir = dir
} }
// Global skills: ~/.config/picoclaw/skills (user-level overrides). // Global skills: ~/.config/dragonscale/skills (user-level overrides).
globalSkillsDir := "" globalSkillsDir := ""
if dir, err := config.ConfigDir(); err == nil { if dir, err := config.ConfigDir(); err == nil {
globalSkillsDir = filepath.Join(dir, "skills") globalSkillsDir = filepath.Join(dir, "skills")
@ -99,9 +100,9 @@ func (cb *ContextBuilder) getIdentity() string {
// Build tools section dynamically // Build tools section dynamically
toolsSection := cb.buildToolsSection() toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# picoclaw 🦞 return fmt.Sprintf(`# dragonscale 🦞
You are picoclaw, a helpful AI assistant. You are dragonscale, a helpful AI assistant.
## Current Time ## Current Time
%s %s
@ -153,12 +154,6 @@ func (cb *ContextBuilder) buildToolsSection() string {
return sb.String() return sb.String()
} }
// roughTokenEstimate gives a conservative char-to-token ratio for budget checks.
// ~4 chars per token for English text is a standard heuristic.
// FIXME: This is a rough estimate and may not be accurate for all languages.
// FIXME: Implement a proper token estimator.
const charsPerToken = 4
func (cb *ContextBuilder) BuildSystemPrompt() string { func (cb *ContextBuilder) BuildSystemPrompt() string {
type section struct { type section struct {
name string name string
@ -183,8 +178,8 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
The following skills extend your capabilities. To use a skill: The following skills extend your capabilities. To use a skill:
1. Use **skill_search** to find relevant skills by keyword 1. Use **skill_search** to find relevant skills by keyword
2. Use **skill_read** via tool_call to load the full skill content 2. Call **skill_read** directly to load the full skill content
3. Use **skill_traverse** via tool_call to explore related skills 3. Call **skill_traverse** directly to explore related skills
Do NOT assume skill content always load before applying. Do NOT assume skill content always load before applying.
@ -215,23 +210,25 @@ Do NOT assume skill content — always load before applying.
// Token budget enforcement: if we exceed ~40% of context window for the // Token budget enforcement: if we exceed ~40% of context window for the
// system prompt, trim lowest-priority sections first. // system prompt, trim lowest-priority sections first.
budgetChars := cb.tokenBudgetChars() budgetTokens := cb.tokenBudgetTokens()
totalChars := 0 totalTokens := 0
for _, s := range sections { sectionTokens := make([]int, len(sections))
totalChars += len(s.content) for i, s := range sections {
sectionTokens[i] = observation.EstimateTokens(s.content)
totalTokens += sectionTokens[i]
} }
if budgetChars > 0 && totalChars > budgetChars { if budgetTokens > 0 && totalTokens > budgetTokens {
logger.WarnCF("context", "System prompt exceeds token budget, trimming low-priority sections", logger.WarnCF("context", "System prompt exceeds token budget, trimming low-priority sections",
map[string]interface{}{ map[string]interface{}{
"total_chars": totalChars, "total_tokens": totalTokens,
"budget_chars": budgetChars, "budget_tokens": budgetTokens,
"sections": len(sections), "sections": len(sections),
}) })
// Trim from lowest priority (highest number) first // Trim from lowest priority (highest number) first
for i := len(sections) - 1; i >= 0 && totalChars > budgetChars; i-- { for i := len(sections) - 1; i >= 0 && totalTokens > budgetTokens; i-- {
if sections[i].priority >= 5 { // only trim P5+ (knowledge, dag) if sections[i].priority >= 5 { // only trim P5+ (knowledge, dag)
totalChars -= len(sections[i].content) totalTokens -= sectionTokens[i]
sections[i].content = "" sections[i].content = ""
} }
} }
@ -247,7 +244,7 @@ Do NOT assume skill content — always load before applying.
prompt := strings.Join(parts, "\n\n---\n\n") prompt := strings.Join(parts, "\n\n---\n\n")
// Log token estimate for observability // Log token estimate for observability
tokenEst := len(prompt) / charsPerToken tokenEst := observation.EstimateTokens(prompt)
logger.DebugCF("context", "System prompt token estimate", logger.DebugCF("context", "System prompt token estimate",
map[string]interface{}{ map[string]interface{}{
"chars": len(prompt), "chars": len(prompt),
@ -258,14 +255,14 @@ Do NOT assume skill content — always load before applying.
return prompt return prompt
} }
// tokenBudgetChars returns the maximum character count for the system prompt, // tokenBudgetTokens returns the maximum token count for the system prompt,
// derived from the context window size. Returns 0 if no limit is configured. // derived from the context window size. Returns 0 if no limit is configured.
func (cb *ContextBuilder) tokenBudgetChars() int { func (cb *ContextBuilder) tokenBudgetTokens() int {
if cb.contextWindow <= 0 { if cb.contextWindow <= 0 {
return 0 return 0
} }
// Reserve ~40% of context window for system prompt // Reserve ~40% of context window for system prompt
return int(float64(cb.contextWindow) * 0.4 * charsPerToken) return int(float64(cb.contextWindow) * 0.4)
} }
func (cb *ContextBuilder) LoadBootstrapFiles() string { func (cb *ContextBuilder) LoadBootstrapFiles() string {
@ -276,7 +273,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "picoclaw", "bootstrap") docs, err := cb.delegate.ListDocumentsByCategory(ctx, "dragonscale", "bootstrap")
if err != nil || len(docs) == 0 { if err != nil || len(docs) == 0 {
return "" return ""
} }
@ -302,7 +299,7 @@ func (cb *ContextBuilder) buildWorkingContextSection() string {
var parts []string var parts []string
// Inject working context (hot tier) // Inject working context (hot tier)
wc, err := cb.memoryStore.GetWorkingContext(ctx, "picoclaw", "default") wc, err := cb.memoryStore.GetWorkingContext(ctx, "dragonscale", "default")
if err == nil && wc != "" { if err == nil && wc != "" {
parts = append(parts, "## Working Context\n\n"+wc) parts = append(parts, "## Working Context\n\n"+wc)
} }

View file

@ -9,9 +9,9 @@ import (
"time" "time"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/ZanzyTHEbar/dragonscale/pkg/config"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/ZanzyTHEbar/dragonscale/pkg/tools"
) )
// --- Mock language model that simulates tool calls --- // --- Mock language model that simulates tool calls ---

File diff suppressed because it is too large Load diff

View file

@ -2,20 +2,29 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"time" "time"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/ZanzyTHEbar/dragonscale/pkg/config"
"github.com/sipeed/picoclaw/pkg/tools" memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
"github.com/ZanzyTHEbar/dragonscale/pkg/messages"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
) )
// mustNewAgentLoop wraps NewAgentLoop and fails the test on error. // mustNewAgentLoop wraps NewAgentLoop and fails the test on error.
func mustNewAgentLoop(t *testing.T, cfg *config.Config, msgBus *bus.MessageBus, model fantasy.LanguageModel) *AgentLoop { func mustNewAgentLoop(t *testing.T, cfg *config.Config, msgBus *bus.MessageBus, model fantasy.LanguageModel) *AgentLoop {
t.Helper() t.Helper()
if cfg != nil && cfg.Memory.DBPath == "" && strings.TrimSpace(cfg.Agents.Defaults.Workspace) != "" {
cfg.Memory.DBPath = filepath.Join(cfg.Agents.Defaults.Workspace, "agent-loop-test.db")
}
al, err := NewAgentLoop(context.Background(), cfg, msgBus, model) al, err := NewAgentLoop(context.Background(), cfg, msgBus, model)
if err != nil { if err != nil {
t.Fatalf("NewAgentLoop: %v", err) t.Fatalf("NewAgentLoop: %v", err)
@ -62,6 +71,125 @@ func (m *mockLanguageModel) StreamObject(_ context.Context, _ fantasy.ObjectCall
func (m *mockLanguageModel) Provider() string { return "mock" } func (m *mockLanguageModel) Provider() string { return "mock" }
func (m *mockLanguageModel) Model() string { return "mock-model" } func (m *mockLanguageModel) Model() string { return "mock-model" }
func TestContinuityKeepCount_UsesConfiguredPolicy(t *testing.T) {
buildHistory := func(n int, content string) []messages.Message {
history := make([]messages.Message, 0, n)
for i := 0; i < n; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
history = append(history, messages.Message{
Role: role,
Content: content,
})
}
return history
}
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ContinuityRetention.MinMessages = 3
cfg.Agents.Defaults.ContinuityRetention.MaxMessages = 7
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0.01
al := &AgentLoop{
cfg: cfg,
contextWindow: 256,
}
history := buildHistory(24, strings.Repeat("long message token payload ", 12))
keepSmallBudget := al.continuityKeepCount(history)
if keepSmallBudget != 3 {
t.Fatalf("expected keep count to respect min_messages=3 under tight budget, got %d", keepSmallBudget)
}
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0.40
al.contextWindow = 8192
keepLargeBudget := al.continuityKeepCount(history)
if keepLargeBudget != 7 {
t.Fatalf("expected keep count to cap at max_messages=7 under large budget, got %d", keepLargeBudget)
}
}
func TestPrepareRuntimeState_ConcurrentSameSessionUsesSingleConversation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
model := newMockLanguageModel("")
al := mustNewAgentLoop(t, cfg, msgBus, model)
beforeConversations, err := al.queries.ListAgentConversations(context.Background(), memsqlc.ListAgentConversationsParams{
Limit: 10000,
})
if err != nil {
t.Fatalf("ListAgentConversations (before) failed: %v", err)
}
beforeCount := len(beforeConversations)
const workers = 12
start := make(chan struct{})
var wg sync.WaitGroup
conversationIDs := make(chan string, workers)
errorsCh := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conversationID, _, prepareErr := al.prepareRuntimeState(context.Background(), "race-session")
if prepareErr != nil {
errorsCh <- prepareErr
return
}
conversationIDs <- conversationID.String()
}()
}
close(start)
wg.Wait()
close(errorsCh)
close(conversationIDs)
for prepareErr := range errorsCh {
if prepareErr != nil {
t.Fatalf("unexpected prepareRuntimeState error: %v", prepareErr)
}
}
uniqueConversationIDs := make(map[string]struct{})
for id := range conversationIDs {
uniqueConversationIDs[id] = struct{}{}
}
if len(uniqueConversationIDs) != 1 {
t.Fatalf("expected one conversation id, got %d (%v)", len(uniqueConversationIDs), uniqueConversationIDs)
}
conversations, err := al.queries.ListAgentConversations(context.Background(), memsqlc.ListAgentConversationsParams{
Limit: 10000,
})
if err != nil {
t.Fatalf("ListAgentConversations failed: %v", err)
}
if len(conversations) != beforeCount+1 {
t.Fatalf("expected conversation count delta +1, got before=%d after=%d", beforeCount, len(conversations))
}
}
func TestRecordLastChannel(t *testing.T) { func TestRecordLastChannel(t *testing.T) {
// Create temp workspace // Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -183,6 +311,36 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
} }
} }
func TestNewAgentLoop_UnifiedKernelDependenciesInitialized(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
model := newMockLanguageModel("")
al := mustNewAgentLoop(t, cfg, msgBus, model)
if !al.HasSecureBus() {
t.Fatal("Expected secure bus to be configured")
}
if !al.HasUnifiedRuntimeDeps() {
t.Fatal("Expected unified runtime dependencies to be configured")
}
}
// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved // TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved
func TestToolRegistry_ToolRegistration(t *testing.T) { func TestToolRegistry_ToolRegistration(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -613,3 +771,161 @@ func TestResolveFinalContent_RecoversFromToolResultText(t *testing.T) {
t.Fatalf("expected tool result text, got %q", got) t.Fatalf("expected tool result text, got %q", got)
} }
} }
// TestForceCompression_PersistsProvenance verifies that emergency compression
// cycles persist provenance metadata to the audit log for postmortem.
func TestForceCompression_PersistsProvenance(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-provenance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Small context window so we can exceed 95% threshold with modest history
// 1000 * 0.95 = 950 tokens; estimateTokens = chars*2/5, so need chars > 2375
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1000,
MaxToolIterations: 10,
ContinuityRetention: config.ContinuityRetentionConfig{
MinMessages: 3,
MaxMessages: 8,
TargetContextRatio: 0.05,
FailureKeepMessages: 8,
},
},
},
}
msgBus := bus.NewMessageBus()
model := newMockLanguageModel("Summary of conversation.")
al := mustNewAgentLoop(t, cfg, msgBus, model)
al.contextWindow = 1000
sessionKey := "provenance-test-session"
// Seed history to exceed critical threshold by a wide margin so this test
// stays deterministic across tokenizer/estimator behavior changes.
const charsPerMsg = 900
for i := 0; i < 16; i++ {
content := fmt.Sprintf("user message %d: %s", i, strings.Repeat("x", charsPerMsg-20))
al.sessions.AddMessage(sessionKey, "user", content)
al.sessions.AddMessage(sessionKey, "assistant", "short reply")
}
al.sessions.Save(sessionKey)
history := al.sessions.GetHistory(sessionKey)
keep := al.continuityKeepCount(history)
if len(history) <= keep {
t.Fatalf("test precondition failed: history=%d keep=%d", len(history), keep)
}
tokenEstimate := al.estimateTokens(history)
criticalThreshold := al.contextWindow * 95 / 100
if tokenEstimate <= criticalThreshold {
t.Fatalf("test precondition failed: token_estimate=%d threshold=%d", tokenEstimate, criticalThreshold)
}
ctx := context.Background()
al.forceCompression(ctx, sessionKey)
del := al.MemoryDelegate()
if del == nil {
t.Fatal("MemoryDelegate is nil")
}
entries, err := del.ListAuditEntriesByAction(ctx, "dragonscale", "emergency_compression", 50)
if err != nil {
t.Fatalf("ListAuditEntriesByAction: %v", err)
}
matching := make([]EmergencyProvenance, 0, len(entries))
for _, entry := range entries {
var prov EmergencyProvenance
if err := json.Unmarshal([]byte(entry.Input), &prov); err != nil {
continue
}
if prov.SessionKey == sessionKey {
matching = append(matching, prov)
}
}
if len(matching) == 0 {
t.Fatal("Expected at least one emergency_compression audit entry")
}
// Verify metadata shape: session_key, cycle, token_estimate, critical_budget
prov := matching[0]
if prov.SessionKey != sessionKey {
t.Errorf("session_key: want %q, got %q", sessionKey, prov.SessionKey)
}
if prov.Cycle < 1 || prov.Cycle > 3 {
t.Errorf("cycle: want 1..3, got %d", prov.Cycle)
}
if prov.TokenEstimate <= 0 {
t.Errorf("token_estimate: want > 0, got %d", prov.TokenEstimate)
}
if prov.CriticalBudget != 950 {
t.Errorf("critical_budget: want 950, got %d", prov.CriticalBudget)
}
if prov.HistoryMsgCount < 8 {
t.Errorf("history_msg_count: want >= 8, got %d", prov.HistoryMsgCount)
}
}
func TestPersistOversizedRecoveryRefs_CreatesRecoverableReferences(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-recovery-ref-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 2048,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
model := newMockLanguageModel("ok")
al := mustNewAgentLoop(t, cfg, msgBus, model)
omitted := []oversizedRecoveryCandidate{
{
Message: messages.Message{
Role: "user",
Content: "omitted oversized content for recovery",
},
OriginalIndex: 2,
TokenEstimate: 9999,
},
}
refs, err := al.persistOversizedRecoveryRefs(context.Background(), "recovery-session", omitted)
if err != nil {
t.Fatalf("persistOversizedRecoveryRefs failed: %v", err)
}
if len(refs) != 1 {
t.Fatalf("expected one recovery ref, got %d", len(refs))
}
dagTool := tools.NewDagExpandTool(tools.DAGToolDeps{
Delegate: al.MemoryDelegate(),
AgentID: "dragonscale",
SessionFn: func() string {
return "recovery-session"
},
})
res := dagTool.Execute(context.Background(), map[string]interface{}{
"node_id": refs[0],
"session_key": "recovery-session",
})
if res.IsError {
t.Fatalf("expected recovery ref expansion to succeed, got: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "omitted oversized content for recovery") {
t.Fatalf("expected recovered content in output, got: %s", res.ForLLM)
}
}

View file

@ -61,17 +61,17 @@ type Config struct {
// Memory is always enabled; there is no opt-out. Configuration controls // Memory is always enabled; there is no opt-out. Configuration controls
// the database path, embedding dimensions, offloading threshold, and sync. // the database path, embedding dimensions, offloading threshold, and sync.
type MemoryConfig struct { type MemoryConfig struct {
// DBPath overrides the default database path (workspace/memory/picoclaw.db). // DBPath overrides the default database path (workspace/memory/dragonscale.db).
// Empty string uses the default. // Empty string uses the default.
DBPath string `json:"db_path" env:"PICOCLAW_MEMORY_DB_PATH"` DBPath string `json:"db_path" env:"DRAGONSCALE_MEMORY_DB_PATH"`
// EmbeddingDims is the vector dimensionality for archival embeddings. // EmbeddingDims is the vector dimensionality for archival embeddings.
// Default: 768 (sentence-transformers). Use 1536 for OpenAI ada-002, 384 for MiniLM. // Default: 768 (sentence-transformers). Use 1536 for OpenAI ada-002, 384 for MiniLM.
EmbeddingDims int `json:"embedding_dims" env:"PICOCLAW_MEMORY_EMBEDDING_DIMS"` EmbeddingDims int `json:"embedding_dims" env:"DRAGONSCALE_MEMORY_EMBEDDING_DIMS"`
// OffloadThresholdTokens is the token count above which tool results // OffloadThresholdTokens is the token count above which tool results
// are automatically offloaded to archival memory. Default: 4000. // are automatically offloaded to archival memory. Default: 4000.
OffloadThresholdTokens int `json:"offload_threshold_tokens" env:"PICOCLAW_MEMORY_OFFLOAD_THRESHOLD_TOKENS"` OffloadThresholdTokens int `json:"offload_threshold_tokens" env:"DRAGONSCALE_MEMORY_OFFLOAD_THRESHOLD_TOKENS"`
// Embedding configures the embedding provider for archival vector search. // Embedding configures the embedding provider for archival vector search.
Embedding EmbeddingConfig `json:"embedding"` Embedding EmbeddingConfig `json:"embedding"`
@ -85,21 +85,21 @@ type MemoryConfig struct {
type EmbeddingConfig struct { type EmbeddingConfig struct {
// Provider selects the embedding backend: "ollama", "openai", or "". // Provider selects the embedding backend: "ollama", "openai", or "".
// Empty string disables embeddings (FTS5-only search). // Empty string disables embeddings (FTS5-only search).
Provider string `json:"provider" env:"PICOCLAW_MEMORY_EMBEDDING_PROVIDER"` Provider string `json:"provider" env:"DRAGONSCALE_MEMORY_EMBEDDING_PROVIDER"`
// Model is the embedding model name (e.g., "nomic-embed-text", "text-embedding-3-small"). // Model is the embedding model name (e.g., "nomic-embed-text", "text-embedding-3-small").
// Defaults depend on provider: "nomic-embed-text" for Ollama, "text-embedding-3-small" for OpenAI. // Defaults depend on provider: "nomic-embed-text" for Ollama, "text-embedding-3-small" for OpenAI.
Model string `json:"model" env:"PICOCLAW_MEMORY_EMBEDDING_MODEL"` Model string `json:"model" env:"DRAGONSCALE_MEMORY_EMBEDDING_MODEL"`
// APIBase overrides the provider's API base URL. // APIBase overrides the provider's API base URL.
// For Ollama defaults to "http://localhost:11434". // For Ollama defaults to "http://localhost:11434".
// For OpenAI defaults to "https://api.openai.com/v1". // For OpenAI defaults to "https://api.openai.com/v1".
// Empty string uses the default for the selected provider. // Empty string uses the default for the selected provider.
APIBase string `json:"api_base" env:"PICOCLAW_MEMORY_EMBEDDING_API_BASE"` APIBase string `json:"api_base" env:"DRAGONSCALE_MEMORY_EMBEDDING_API_BASE"`
// APIKey for the embedding provider. Required for OpenAI, optional for Ollama. // APIKey for the embedding provider. Required for OpenAI, optional for Ollama.
// If empty, falls back to the matching provider's key from providers config. // If empty, falls back to the matching provider's key from providers config.
APIKey string `json:"api_key" env:"PICOCLAW_MEMORY_EMBEDDING_API_KEY"` APIKey string `json:"api_key" env:"DRAGONSCALE_MEMORY_EMBEDDING_API_KEY"`
} }
// MemorySyncConfig configures Turso embedded replica synchronization. // MemorySyncConfig configures Turso embedded replica synchronization.
@ -107,38 +107,57 @@ type EmbeddingConfig struct {
type MemorySyncConfig struct { type MemorySyncConfig struct {
// SyncURL is the Turso primary database URL (e.g., "libsql://mydb.turso.io"). // SyncURL is the Turso primary database URL (e.g., "libsql://mydb.turso.io").
// Empty string disables replication (local-only mode). // Empty string disables replication (local-only mode).
SyncURL string `json:"sync_url" env:"PICOCLAW_MEMORY_SYNC_URL"` SyncURL string `json:"sync_url" env:"DRAGONSCALE_MEMORY_SYNC_URL"`
// AuthToken is the Turso authentication token for the remote database. // AuthToken is the Turso authentication token for the remote database.
AuthToken string `json:"auth_token" env:"PICOCLAW_MEMORY_SYNC_AUTH_TOKEN"` AuthToken string `json:"auth_token" env:"DRAGONSCALE_MEMORY_SYNC_AUTH_TOKEN"`
// SyncIntervalSeconds is how often to sync with the remote primary (in seconds). // SyncIntervalSeconds is how often to sync with the remote primary (in seconds).
// Zero means manual sync only. Default: 60. // Zero means manual sync only. Default: 60.
SyncIntervalSeconds int `json:"sync_interval_seconds" env:"PICOCLAW_MEMORY_SYNC_INTERVAL_SECONDS"` SyncIntervalSeconds int `json:"sync_interval_seconds" env:"DRAGONSCALE_MEMORY_SYNC_INTERVAL_SECONDS"`
// EncryptionKey enables encryption-at-rest on the local database file. // EncryptionKey enables encryption-at-rest on the local database file.
// Empty string means no encryption. // Empty string means no encryption.
EncryptionKey string `json:"encryption_key" env:"PICOCLAW_MEMORY_SYNC_ENCRYPTION_KEY"` EncryptionKey string `json:"encryption_key" env:"DRAGONSCALE_MEMORY_SYNC_ENCRYPTION_KEY"`
} }
type AgentsConfig struct { type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"` Defaults AgentDefaults `json:"defaults"`
} }
type ContinuityRetentionConfig struct {
// MinMessages is the minimum number of recent messages always retained
// unsummarized for conversational continuity.
MinMessages int `json:"min_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_MIN_MESSAGES"`
// MaxMessages is the upper bound on retained recent messages, even when
// the token budget would allow more.
MaxMessages int `json:"max_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_MAX_MESSAGES"`
// TargetContextRatio is the target fraction of model context window reserved
// for retained recent messages.
TargetContextRatio float64 `json:"target_context_ratio" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_TARGET_CONTEXT_RATIO"`
// FailureKeepMessages is the fallback retained-message count used when
// summarization repeatedly fails.
FailureKeepMessages int `json:"failure_keep_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_FAILURE_KEEP_MESSAGES"`
}
type AgentDefaults struct { type AgentDefaults struct {
// Sandbox is the directory for agent file operations (tools sandbox). // Sandbox is the directory for agent file operations (tools sandbox).
// Defaults to $XDG_DATA_HOME/picoclaw/sandbox when empty. // Defaults to $XDG_DATA_HOME/dragonscale/sandbox when empty.
Sandbox string `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"` Sandbox string `json:"sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_SANDBOX"`
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"` RestrictToSandbox bool `json:"restrict_to_sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"DRAGONSCALE_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` Model string `json:"model" env:"DRAGONSCALE_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` MaxTokens int `json:"max_tokens" env:"DRAGONSCALE_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` Temperature float64 `json:"temperature" env:"DRAGONSCALE_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` MaxToolIterations int `json:"max_tool_iterations" env:"DRAGONSCALE_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
ContinuityRetention ContinuityRetentionConfig `json:"continuity_retention"`
// Deprecated: Use Sandbox instead. Kept for backward compatibility during migration. // Deprecated: Use Sandbox instead. Kept for backward compatibility during migration.
Workspace string `json:"workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` Workspace string `json:"workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
} }
type ChannelsConfig struct { type ChannelsConfig struct {
@ -155,88 +174,88 @@ type ChannelsConfig struct {
} }
type WhatsAppConfig struct { type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` BridgeURL string `json:"bridge_url" env:"DRAGONSCALE_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ALLOW_FROM"`
} }
type TelegramConfig struct { type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Token string `json:"token" env:"DRAGONSCALE_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Proxy string `json:"proxy" env:"DRAGONSCALE_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ALLOW_FROM"`
} }
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` EncryptKey string `json:"encrypt_key" env:"DRAGONSCALE_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` VerificationToken string `json:"verification_token" env:"DRAGONSCALE_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_FEISHU_ALLOW_FROM"`
} }
type DiscordConfig struct { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"DRAGONSCALE_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DISCORD_ALLOW_FROM"`
} }
type MaixCamConfig struct { type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Host string `json:"host" env:"DRAGONSCALE_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` Port int `json:"port" env:"DRAGONSCALE_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ALLOW_FROM"`
} }
type QQConfig struct { type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_QQ_ALLOW_FROM"`
} }
type DingTalkConfig struct { type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientID string `json:"client_id" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` ClientSecret string `json:"client_secret" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DINGTALK_ALLOW_FROM"`
} }
type SlackConfig struct { type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` BotToken string `json:"bot_token" env:"DRAGONSCALE_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AppToken string `json:"app_token" env:"DRAGONSCALE_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_SLACK_ALLOW_FROM"`
} }
type LINEConfig struct { type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelSecret string `json:"channel_secret" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` ChannelAccessToken string `json:"channel_access_token" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` WebhookHost string `json:"webhook_host" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPort int `json:"webhook_port" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` WebhookPath string `json:"webhook_path" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_LINE_ALLOW_FROM"`
} }
type OneBotConfig struct { type OneBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` WSUrl string `json:"ws_url" env:"DRAGONSCALE_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` AccessToken string `json:"access_token" env:"DRAGONSCALE_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` ReconnectInterval int `json:"reconnect_interval" env:"DRAGONSCALE_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"DRAGONSCALE_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_ONEBOT_ALLOW_FROM"`
} }
type HeartbeatConfig struct { type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 Interval int `json:"interval" env:"DRAGONSCALE_HEARTBEAT_INTERVAL"` // minutes, min 5
} }
type DevicesConfig struct { type DevicesConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` MonitorUSB bool `json:"monitor_usb" env:"DRAGONSCALE_DEVICES_MONITOR_USB"`
} }
type ProvidersConfig struct { type ProvidersConfig struct {
@ -255,40 +274,70 @@ type ProvidersConfig struct {
GitHubCopilot ProviderConfig `json:"github_copilot"` GitHubCopilot ProviderConfig `json:"github_copilot"`
} }
// ConfiguredNames returns the names of providers that have credentials set
// (either an API key or an API base URL for local inference servers).
func (p ProvidersConfig) ConfiguredNames() []string {
entries := []struct {
name string
key string
}{
{"anthropic", p.Anthropic.APIKey},
{"openai", p.OpenAI.APIKey},
{"openrouter", p.OpenRouter.APIKey},
{"gemini", p.Gemini.APIKey},
{"groq", p.Groq.APIKey},
{"zhipu", p.Zhipu.APIKey},
{"deepseek", p.DeepSeek.APIKey},
{"moonshot", p.Moonshot.APIKey},
{"nvidia", p.Nvidia.APIKey},
{"shengsuanyun", p.ShengSuanYun.APIKey},
{"ollama", p.Ollama.APIBase},
{"vllm", p.VLLM.APIBase},
{"github_copilot", p.GitHubCopilot.APIKey},
}
var names []string
for _, e := range entries {
if e.key != "" {
names = append(names, e.name)
}
}
return names
}
type ProviderConfig struct { type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` APIBase string `json:"api_base" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` Proxy string `json:"proxy,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` AuthMethod string `json:"auth_method,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s) Timeout int `json:"timeout,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
ConnectMode string `json:"connect_mode,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` ConnectMode string `json:"connect_mode,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
} }
type OpenAIProviderConfig struct { type OpenAIProviderConfig struct {
ProviderConfig ProviderConfig
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` WebSearch bool `json:"web_search" env:"DRAGONSCALE_PROVIDERS_OPENAI_WEB_SEARCH"`
} }
type GatewayConfig struct { type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Host string `json:"host" env:"DRAGONSCALE_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"DRAGONSCALE_GATEWAY_PORT"`
} }
type BraveConfig struct { type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_MAX_RESULTS"`
} }
type DuckDuckGoConfig struct { type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
} }
type PerplexityConfig struct { type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
} }
type WebToolsConfig struct { type WebToolsConfig struct {
@ -298,7 +347,7 @@ type WebToolsConfig struct {
} }
type CronToolsConfig struct { type CronToolsConfig struct {
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"DRAGONSCALE_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
} }
type ToolsConfig struct { type ToolsConfig struct {
@ -317,6 +366,12 @@ func DefaultConfig() *Config {
MaxTokens: 8192, MaxTokens: 8192,
Temperature: 0.7, Temperature: 0.7,
MaxToolIterations: 20, MaxToolIterations: 20,
ContinuityRetention: ContinuityRetentionConfig{
MinMessages: 4,
MaxMessages: 24,
TargetContextRatio: 0.10,
FailureKeepMessages: 10,
},
}, },
}, },
Channels: ChannelsConfig{ Channels: ChannelsConfig{
@ -493,6 +548,23 @@ func (c *Config) Validate() []string {
warnings = append(warnings, fmt.Sprintf("agents.defaults.max_tool_iterations=%d: should be > 0", c.Agents.Defaults.MaxToolIterations)) warnings = append(warnings, fmt.Sprintf("agents.defaults.max_tool_iterations=%d: should be > 0", c.Agents.Defaults.MaxToolIterations))
} }
continuity := c.Agents.Defaults.ContinuityRetention
if continuity.MinMessages <= 0 {
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.min_messages=%d: should be > 0", continuity.MinMessages))
}
if continuity.MaxMessages <= 0 {
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.max_messages=%d: should be > 0", continuity.MaxMessages))
}
if continuity.MaxMessages > 0 && continuity.MinMessages > continuity.MaxMessages {
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.min_messages=%d exceeds max_messages=%d", continuity.MinMessages, continuity.MaxMessages))
}
if continuity.TargetContextRatio <= 0 || continuity.TargetContextRatio > 0.5 {
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.target_context_ratio=%.4f: expected (0, 0.5]", continuity.TargetContextRatio))
}
if continuity.FailureKeepMessages <= 0 {
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.failure_keep_messages=%d: should be > 0", continuity.FailureKeepMessages))
}
if c.Gateway.Port < 0 || c.Gateway.Port > 65535 { if c.Gateway.Port < 0 || c.Gateway.Port > 65535 {
warnings = append(warnings, fmt.Sprintf("gateway.port=%d: must be in range 1-65535", c.Gateway.Port)) warnings = append(warnings, fmt.Sprintf("gateway.port=%d: must be in range 1-65535", c.Gateway.Port))
} }
@ -569,7 +641,7 @@ func (c *Config) SandboxPath() string {
if dir, err := SandboxDir(); err == nil { if dir, err := SandboxDir(); err == nil {
return dir return dir
} }
return expandHome("~/.local/share/picoclaw/sandbox") return expandHome("~/.local/share/dragonscale/sandbox")
} }
// RestrictToSandbox returns whether tool file operations should be restricted // RestrictToSandbox returns whether tool file operations should be restricted
@ -602,7 +674,7 @@ func (c *Config) DBPath() string {
if p, err := DefaultDBPath(); err == nil { if p, err := DefaultDBPath(); err == nil {
return p return p
} }
return expandHome("~/.local/share/picoclaw/picoclaw.db") return expandHome("~/.local/share/dragonscale/dragonscale.db")
} }
func (c *Config) GetAPIKey() string { func (c *Config) GetAPIKey() string {
@ -669,12 +741,12 @@ func expandHome(path string) string {
// ─── XDG / platform path helpers ───────────────────────────────────────────── // ─── XDG / platform path helpers ─────────────────────────────────────────────
const appName = "picoclaw" const appName = "dragonscale"
// ConfigDir returns the platform-appropriate user configuration directory for // ConfigDir returns the platform-appropriate user configuration directory for
// picoclaw, following XDG Base Directory spec on Linux // dragonscale, following XDG Base Directory spec on Linux
// (~/.config/picoclaw), Library/Application Support on macOS, and // (~/.config/dragonscale), Library/Application Support on macOS, and
// %AppData%\picoclaw on Windows. The directory is created if it does not exist. // %AppData%\dragonscale on Windows. The directory is created if it does not exist.
func ConfigDir() (string, error) { func ConfigDir() (string, error) {
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
@ -687,10 +759,10 @@ func ConfigDir() (string, error) {
return dir, nil return dir, nil
} }
// DataDir returns the platform-appropriate user data directory for picoclaw. // DataDir returns the platform-appropriate user data directory for dragonscale.
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/picoclaw). // On Linux this respects XDG_DATA_HOME (default ~/.local/share/dragonscale).
// On macOS it uses ~/Library/Application Support/picoclaw; on Windows // On macOS it uses ~/Library/Application Support/dragonscale; on Windows
// %LOCALAPPDATA%\picoclaw. The directory is created if it does not exist. // %LOCALAPPDATA%\dragonscale. The directory is created if it does not exist.
func DataDir() (string, error) { func DataDir() (string, error) {
var base string var base string
switch runtime.GOOS { switch runtime.GOOS {
@ -765,8 +837,8 @@ func SandboxDir() (string, error) {
return dir, nil return dir, nil
} }
// CacheDir returns the platform-appropriate user cache directory for picoclaw // CacheDir returns the platform-appropriate user cache directory for dragonscale
// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if // (XDG_CACHE_HOME on Linux → ~/.cache/dragonscale). The directory is created if
// it does not exist. // it does not exist.
func CacheDir() (string, error) { func CacheDir() (string, error) {
base, err := os.UserCacheDir() base, err := os.UserCacheDir()
@ -782,7 +854,7 @@ func CacheDir() (string, error) {
// DefaultDBPath returns the canonical SQLite database path inside DataDir. // DefaultDBPath returns the canonical SQLite database path inside DataDir.
// Callers that want to override this should check for a CLI flag or the // Callers that want to override this should check for a CLI flag or the
// PICOCLAW_DB_PATH environment variable before falling back to this value. // DRAGONSCALE_DB_PATH environment variable before falling back to this value.
func DefaultDBPath() (string, error) { func DefaultDBPath() (string, error) {
dataDir, err := DataDir() dataDir, err := DataDir()
if err != nil { if err != nil {
@ -792,7 +864,7 @@ func DefaultDBPath() (string, error) {
} }
// DefaultConfigPath returns the path to the primary JSON config file inside // DefaultConfigPath returns the path to the primary JSON config file inside
// ConfigDir (picoclaw/config.json). // ConfigDir (dragonscale/config.json).
func DefaultConfigPath() (string, error) { func DefaultConfigPath() (string, error) {
cfgDir, err := ConfigDir() cfgDir, err := ConfigDir()
if err != nil { if err != nil {

View file

@ -53,6 +53,23 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
} }
} }
func TestDefaultConfig_ContinuityRetention(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.ContinuityRetention.MinMessages <= 0 {
t.Error("ContinuityRetention.MinMessages should be > 0")
}
if cfg.Agents.Defaults.ContinuityRetention.MaxMessages < cfg.Agents.Defaults.ContinuityRetention.MinMessages {
t.Error("ContinuityRetention.MaxMessages should be >= MinMessages")
}
if cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio <= 0 {
t.Error("ContinuityRetention.TargetContextRatio should be > 0")
}
if cfg.Agents.Defaults.ContinuityRetention.FailureKeepMessages < cfg.Agents.Defaults.ContinuityRetention.MinMessages {
t.Error("ContinuityRetention.FailureKeepMessages should be >= MinMessages")
}
}
// TestDefaultConfig_Temperature verifies temperature has default value // TestDefaultConfig_Temperature verifies temperature has default value
func TestDefaultConfig_Temperature(t *testing.T) { func TestDefaultConfig_Temperature(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -300,6 +317,26 @@ func TestValidate_MemoryConfig(t *testing.T) {
} }
} }
func TestValidate_ContinuityRetentionConfig(t *testing.T) {
cfg := DefaultConfig()
cfg.Agents.Defaults.ContinuityRetention.MinMessages = 8
cfg.Agents.Defaults.ContinuityRetention.MaxMessages = 4
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0
cfg.Agents.Defaults.ContinuityRetention.FailureKeepMessages = 0
warnings := cfg.Validate()
joined := strings.Join(warnings, "\n")
if !strings.Contains(joined, "continuity_retention.min_messages") {
t.Fatalf("expected continuity retention min/max warning, got: %v", warnings)
}
if !strings.Contains(joined, "continuity_retention.target_context_ratio") {
t.Fatalf("expected continuity retention ratio warning, got: %v", warnings)
}
if !strings.Contains(joined, "continuity_retention.failure_keep_messages") {
t.Fatalf("expected continuity retention failure keep warning, got: %v", warnings)
}
}
func containsMemoryWarning(s string) bool { func containsMemoryWarning(s string) bool {
return strings.Contains(s, "memory.") return strings.Contains(s, "memory.")
} }

44
pkg/tools/spawn_test.go Normal file
View file

@ -0,0 +1,44 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
)
func TestSpawnTool_Execute_NestedDelegationGuardrails(t *testing.T) {
provider := &MockLanguageModel{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, _, _, _ string) (*ToolLoopResult, error) {
return &ToolLoopResult{Content: "ok", Iterations: 1}, nil
})
tool := NewSpawnTool(manager)
ctx := withDelegationContext(context.Background(), "parent", 1)
missingMetadata := tool.Execute(ctx, map[string]interface{}{
"task": "nested task",
"label": "n1",
})
if !missingMetadata.IsError {
t.Fatal("expected missing delegated metadata to fail")
}
if !strings.Contains(missingMetadata.ForLLM, "nested delegation requires delegated_scope and kept_work") {
t.Fatalf("unexpected error: %s", missingMetadata.ForLLM)
}
withMetadata := tool.Execute(ctx, map[string]interface{}{
"task": "nested task",
"label": "n2",
"delegated_scope": "collect upstream context",
"kept_work": "final answer synthesis",
})
if withMetadata.IsError {
t.Fatalf("expected nested delegation with metadata to succeed: %s", withMetadata.ForLLM)
}
if !withMetadata.Async {
t.Fatal("expected spawn tool to return async result")
}
}

View file

@ -3,69 +3,150 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/ZanzyTHEbar/dragonscale/pkg/bus"
) )
type SubagentTask struct { type SubagentTask struct {
ID string ID string
Task string ParentTaskID string
Label string Depth int
OriginChannel string Task string
OriginChatID string Label string
Status string DelegatedScope string
Result string KeptWork string
Created int64 OriginChannel string
OriginChatID string
Status string
Result string
Created int64
} }
// RunLoopFunc executes an agent tool loop. Injected from pkg/agent to break // RunLoopFunc executes an agent tool loop. Injected from pkg/agent to break
// the import cycle between pkg/tools and pkg/fantasy. // the import cycle between pkg/tools and pkg/fantasy.
type RunLoopFunc func(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error) type RunLoopFunc func(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error)
type delegationCtxKey string
const (
delegationTaskIDKey delegationCtxKey = "delegation_task_id"
delegationDepthKey delegationCtxKey = "delegation_depth"
)
func delegationTaskIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(delegationTaskIDKey).(string); ok {
return v
}
return ""
}
func delegationDepthFromContext(ctx context.Context) int {
if v, ok := ctx.Value(delegationDepthKey).(int); ok {
return v
}
return 0
}
func withDelegationContext(ctx context.Context, taskID string, depth int) context.Context {
ctx = context.WithValue(ctx, delegationTaskIDKey, taskID)
ctx = context.WithValue(ctx, delegationDepthKey, depth)
return ctx
}
// DelegationAuditEvent captures lineage and outcomes for delegated work.
type DelegationAuditEvent struct {
TaskID string
ParentTaskID string
Mode string
Depth int
Status string
Label string
DelegatedScope string
KeptWork string
Iterations int
ResultChars int
Error string
OriginChannel string
OriginChatID string
}
type SubagentManager struct { type SubagentManager struct {
tasks map[string]*SubagentTask tasks map[string]*SubagentTask
mu sync.RWMutex mu sync.RWMutex
model fantasy.LanguageModel model fantasy.LanguageModel
defaultModel string defaultModel string
bus *bus.MessageBus bus *bus.MessageBus
workspace string workspace string
tools *ToolRegistry tools *ToolRegistry
maxIterations int maxIterations int
nextID int maxDepth int
runLoop RunLoopFunc maxFanout int
activeChildren map[string]int
nextID int
runLoop RunLoopFunc
auditHook func(context.Context, DelegationAuditEvent)
} }
func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager { func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
return &SubagentManager{ return &SubagentManager{
tasks: make(map[string]*SubagentTask), tasks: make(map[string]*SubagentTask),
model: model, model: model,
defaultModel: defaultModel, defaultModel: defaultModel,
bus: bus, bus: bus,
workspace: workspace, workspace: workspace,
tools: NewToolRegistry(), tools: NewToolRegistry(),
maxIterations: 10, maxIterations: 10,
nextID: 1, maxDepth: 3,
maxFanout: 4,
activeChildren: make(map[string]int),
nextID: 1,
} }
} }
// SetRunLoop injects the loop runner function. Must be called before any // SetRunLoop injects the loop runner function. Must be called before any
// subagent execution. When nil, falls back to the local RunToolLoop. // subagent execution.
func (sm *SubagentManager) SetRunLoop(fn RunLoopFunc) { func (sm *SubagentManager) SetRunLoop(fn RunLoopFunc) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
sm.runLoop = fn sm.runLoop = fn
} }
// SetDelegationLimits configures nested delegation guardrails.
func (sm *SubagentManager) SetDelegationLimits(maxDepth, maxFanout int) {
sm.mu.Lock()
defer sm.mu.Unlock()
if maxDepth > 0 {
sm.maxDepth = maxDepth
}
if maxFanout > 0 {
sm.maxFanout = maxFanout
}
}
// SetAuditHook registers a callback for delegation lineage/events.
func (sm *SubagentManager) SetAuditHook(hook func(context.Context, DelegationAuditEvent)) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.auditHook = hook
}
func (sm *SubagentManager) emitAudit(ctx context.Context, evt DelegationAuditEvent) {
sm.mu.RLock()
hook := sm.auditHook
sm.mu.RUnlock()
if hook != nil {
hook(ctx, evt)
}
}
func (sm *SubagentManager) getRunLoop() RunLoopFunc { func (sm *SubagentManager) getRunLoop() RunLoopFunc {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
if sm.runLoop != nil { return sm.runLoop
return sm.runLoop
}
return RunToolLoop
} }
// SetTools sets the tool registry for subagent execution. // SetTools sets the tool registry for subagent execution.
@ -82,23 +163,66 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
sm.tools.Register(tool) sm.tools.Register(tool)
} }
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string, callback AsyncCallback) (string, error) { func (sm *SubagentManager) Spawn(ctx context.Context, task, label, delegatedScope, keptWork, originChannel, originChatID string, callback AsyncCallback) (string, error) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() parentTaskID := delegationTaskIDFromContext(ctx)
if parentTaskID == "" {
parentTaskID = "root"
}
parentDepth := delegationDepthFromContext(ctx)
childDepth := parentDepth + 1
if childDepth > sm.maxDepth {
sm.mu.Unlock()
return "", fmt.Errorf("delegation depth exceeded: %d > %d", childDepth, sm.maxDepth)
}
if sm.activeChildren[parentTaskID] >= sm.maxFanout {
sm.mu.Unlock()
return "", fmt.Errorf("delegation fanout exceeded for %s: %d >= %d", parentTaskID, sm.activeChildren[parentTaskID], sm.maxFanout)
}
if parentDepth > 0 {
if strings.TrimSpace(delegatedScope) == "" || strings.TrimSpace(keptWork) == "" {
sm.mu.Unlock()
return "", fmt.Errorf("nested delegation requires delegated_scope and kept_work")
}
}
if sm.runLoop == nil {
sm.mu.Unlock()
return "", ErrRunLoopNotConfigured
}
taskID := fmt.Sprintf("subagent-%d", sm.nextID) taskID := fmt.Sprintf("subagent-%d", sm.nextID)
sm.nextID++ sm.nextID++
sm.activeChildren[parentTaskID]++
subagentTask := &SubagentTask{ subagentTask := &SubagentTask{
ID: taskID, ID: taskID,
Task: task, ParentTaskID: parentTaskID,
Label: label, Depth: childDepth,
OriginChannel: originChannel, Task: task,
OriginChatID: originChatID, Label: label,
Status: "running", DelegatedScope: delegatedScope,
Created: time.Now().UnixMilli(), KeptWork: keptWork,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: time.Now().UnixMilli(),
} }
sm.tasks[taskID] = subagentTask sm.tasks[taskID] = subagentTask
sm.mu.Unlock()
sm.emitAudit(ctx, DelegationAuditEvent{
TaskID: taskID,
ParentTaskID: parentTaskID,
Mode: "spawn",
Depth: childDepth,
Status: "created",
Label: label,
DelegatedScope: delegatedScope,
KeptWork: keptWork,
OriginChannel: originChannel,
OriginChatID: originChatID,
})
// Start task in background with context cancellation support // Start task in background with context cancellation support
go sm.runTask(ctx, subagentTask, callback) go sm.runTask(ctx, subagentTask, callback)
@ -110,12 +234,10 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel
} }
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
task.Status = "running" systemPrompt := `You are a subagent operating under the same runtime discipline as the main agent.
task.Created = time.Now().UnixMilli() Use tools for actions. Do not claim actions without tool execution.
When discovering tools, call discovered tools directly; use tool_call only as fallback.
systemPrompt := `You are a subagent. Complete the given task independently and report the result. Complete the task independently and provide a clear summary of what was done.`
You have access to tools - use them as needed to complete your task.
After completing the task, provide a clear summary of what was done.`
// Check if context is already cancelled before starting // Check if context is already cancelled before starting
select { select {
@ -135,18 +257,51 @@ After completing the task, provide a clear summary of what was done.`
sm.mu.RUnlock() sm.mu.RUnlock()
runLoop := sm.getRunLoop() runLoop := sm.getRunLoop()
loopResult, err := runLoop(ctx, ToolLoopConfig{ var loopResult *ToolLoopResult
Model: sm.model, var err error
ModelID: sm.defaultModel, if runLoop == nil {
Tools: tools, err = ErrRunLoopNotConfigured
Bus: sm.bus, } else {
MaxIterations: maxIter, taskCtx := withDelegationContext(ctx, task.ID, task.Depth)
}, systemPrompt, task.Task, task.OriginChannel, task.OriginChatID) loopResult, err = runLoop(taskCtx, ToolLoopConfig{
Model: sm.model,
ModelID: sm.defaultModel,
Tools: tools,
Bus: sm.bus,
MaxIterations: maxIter,
}, systemPrompt, task.Task, task.OriginChannel, task.OriginChatID)
}
sm.mu.Lock() sm.mu.Lock()
var result *ToolResult var result *ToolResult
iterations := 0
resultChars := 0
errText := ""
finalStatus := task.Status
defer func() { defer func() {
if n := sm.activeChildren[task.ParentTaskID]; n <= 1 {
delete(sm.activeChildren, task.ParentTaskID)
} else {
sm.activeChildren[task.ParentTaskID] = n - 1
}
finalStatus = task.Status
resultChars = len(task.Result)
sm.mu.Unlock() sm.mu.Unlock()
sm.emitAudit(ctx, DelegationAuditEvent{
TaskID: task.ID,
ParentTaskID: task.ParentTaskID,
Mode: "spawn",
Depth: task.Depth,
Status: finalStatus,
Label: task.Label,
DelegatedScope: task.DelegatedScope,
KeptWork: task.KeptWork,
Iterations: iterations,
ResultChars: resultChars,
Error: errText,
OriginChannel: task.OriginChannel,
OriginChatID: task.OriginChatID,
})
if callback != nil && result != nil { if callback != nil && result != nil {
callback(ctx, result) callback(ctx, result)
} }
@ -155,6 +310,7 @@ After completing the task, provide a clear summary of what was done.`
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err) task.Result = fmt.Sprintf("Error: %v", err)
errText = err.Error()
if ctx.Err() != nil { if ctx.Err() != nil {
task.Status = "cancelled" task.Status = "cancelled"
task.Result = "Task cancelled during execution" task.Result = "Task cancelled during execution"
@ -170,6 +326,7 @@ After completing the task, provide a clear summary of what was done.`
} else { } else {
task.Status = "completed" task.Status = "completed"
task.Result = loopResult.Content task.Result = loopResult.Content
iterations = loopResult.Iterations
result = &ToolResult{ result = &ToolResult{
ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content), ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
ForUser: loopResult.Content, ForUser: loopResult.Content,
@ -195,7 +352,11 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
task, ok := sm.tasks[taskID] task, ok := sm.tasks[taskID]
return task, ok if !ok || task == nil {
return nil, false
}
copied := *task
return &copied, true
} }
func (sm *SubagentManager) ListTasks() []*SubagentTask { func (sm *SubagentManager) ListTasks() []*SubagentTask {
@ -204,7 +365,11 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
tasks := make([]*SubagentTask, 0, len(sm.tasks)) tasks := make([]*SubagentTask, 0, len(sm.tasks))
for _, task := range sm.tasks { for _, task := range sm.tasks {
tasks = append(tasks, task) if task == nil {
continue
}
copied := *task
tasks = append(tasks, &copied)
} }
return tasks return tasks
} }
@ -244,6 +409,14 @@ func (t *SubagentTool) Parameters() map[string]interface{} {
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"delegated_scope": map[string]interface{}{
"type": "string",
"description": "What part of the parent task is being delegated. Required for nested delegation.",
},
"kept_work": map[string]interface{}{
"type": "string",
"description": "What work remains with the delegator. Required for nested delegation.",
},
}, },
"required": []string{"task"}, "required": []string{"task"},
} }
@ -261,21 +434,73 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)
delegatedScope, _ := args["delegated_scope"].(string)
keptWork, _ := args["kept_work"].(string)
if t.manager == nil { if t.manager == nil {
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
} }
systemPrompt := "You are a subagent. Complete the given task independently and provide a clear, concise result."
sm := t.manager sm := t.manager
parentTaskID := delegationTaskIDFromContext(ctx)
if parentTaskID == "" {
parentTaskID = "root"
}
parentDepth := delegationDepthFromContext(ctx)
childDepth := parentDepth + 1
sm.mu.RLock() sm.mu.RLock()
tools := sm.tools tools := sm.tools
maxIter := sm.maxIterations maxIter := sm.maxIterations
maxDepth := sm.maxDepth
sm.mu.RUnlock() sm.mu.RUnlock()
if childDepth > maxDepth {
return ErrorResult(fmt.Sprintf("delegation depth exceeded: %d > %d", childDepth, maxDepth))
}
if parentDepth > 0 {
if strings.TrimSpace(delegatedScope) == "" || strings.TrimSpace(keptWork) == "" {
return ErrorResult("nested delegation requires delegated_scope and kept_work")
}
}
systemPrompt := "You are a subagent operating with main-loop control flow. Execute actions via tools, call discovered tools directly, and provide a clear concise result."
sm.mu.Lock()
if sm.activeChildren[parentTaskID] >= sm.maxFanout {
sm.mu.Unlock()
return ErrorResult(fmt.Sprintf("delegation fanout exceeded for %s: %d >= %d", parentTaskID, sm.activeChildren[parentTaskID], sm.maxFanout))
}
sm.activeChildren[parentTaskID]++
sm.mu.Unlock()
defer func() {
sm.mu.Lock()
if n := sm.activeChildren[parentTaskID]; n <= 1 {
delete(sm.activeChildren, parentTaskID)
} else {
sm.activeChildren[parentTaskID] = n - 1
}
sm.mu.Unlock()
}()
taskID := fmt.Sprintf("subagent-sync-%d", time.Now().UnixNano())
taskCtx := withDelegationContext(ctx, taskID, childDepth)
sm.emitAudit(ctx, DelegationAuditEvent{
TaskID: taskID,
ParentTaskID: parentTaskID,
Mode: "sync",
Depth: childDepth,
Status: "created",
Label: label,
DelegatedScope: delegatedScope,
KeptWork: keptWork,
OriginChannel: t.originChannel,
OriginChatID: t.originChatID,
})
runLoop := sm.getRunLoop() runLoop := sm.getRunLoop()
loopResult, err := runLoop(ctx, ToolLoopConfig{ if runLoop == nil {
return ErrorResult("Subagent runtime is not configured").WithError(ErrRunLoopNotConfigured)
}
loopResult, err := runLoop(taskCtx, ToolLoopConfig{
Model: sm.model, Model: sm.model,
ModelID: sm.defaultModel, ModelID: sm.defaultModel,
Tools: tools, Tools: tools,
@ -284,6 +509,19 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
}, systemPrompt, task, t.originChannel, t.originChatID) }, systemPrompt, task, t.originChannel, t.originChatID)
if err != nil { if err != nil {
sm.emitAudit(ctx, DelegationAuditEvent{
TaskID: taskID,
ParentTaskID: parentTaskID,
Mode: "sync",
Depth: childDepth,
Status: "failed",
Label: label,
DelegatedScope: delegatedScope,
KeptWork: keptWork,
Error: err.Error(),
OriginChannel: t.originChannel,
OriginChatID: t.originChatID,
})
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
} }
@ -301,6 +539,20 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
} }
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s", llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s",
labelStr, loopResult.Iterations, loopResult.Content) labelStr, loopResult.Iterations, loopResult.Content)
sm.emitAudit(ctx, DelegationAuditEvent{
TaskID: taskID,
ParentTaskID: parentTaskID,
Mode: "sync",
Depth: childDepth,
Status: "completed",
Label: label,
DelegatedScope: delegatedScope,
KeptWork: keptWork,
Iterations: loopResult.Iterations,
ResultChars: len(loopResult.Content),
OriginChannel: t.originChannel,
OriginChatID: t.originChatID,
})
return &ToolResult{ return &ToolResult{
ForLLM: llmContent, ForLLM: llmContent,

View file

@ -0,0 +1,227 @@
package tools
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
)
func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("condition not met within %s", timeout)
}
func TestSubagentManager_SpawnRequiresRunLoop(t *testing.T) {
provider := &MockLanguageModel{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
_, err := manager.Spawn(context.Background(), "task-without-loop", "label", "", "", "cli", "chat", nil)
if err == nil {
t.Fatal("expected spawn to fail when run loop is not configured")
}
if !strings.Contains(err.Error(), ErrRunLoopNotConfigured.Error()) {
t.Fatalf("expected run loop contract error, got: %v", err)
}
}
func TestSubagentManager_SpawnDelegationGuardrails(t *testing.T) {
provider := &MockLanguageModel{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
manager.SetDelegationLimits(2, 1)
block := make(chan struct{})
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, _, _, _ string) (*ToolLoopResult, error) {
<-block
return &ToolLoopResult{Content: "done", Iterations: 1}, nil
})
_, err := manager.Spawn(context.Background(), "task-1", "one", "", "", "cli", "chat", nil)
if err != nil {
t.Fatalf("first spawn should succeed: %v", err)
}
_, err = manager.Spawn(context.Background(), "task-2", "two", "", "", "cli", "chat", nil)
if err == nil || !strings.Contains(err.Error(), "delegation fanout exceeded") {
t.Fatalf("expected fanout error, got: %v", err)
}
nestedCtx := withDelegationContext(context.Background(), "parent", 1)
_, err = manager.Spawn(nestedCtx, "task-3", "three", "", "", "cli", "chat", nil)
if err == nil || !strings.Contains(err.Error(), "nested delegation requires delegated_scope and kept_work") {
t.Fatalf("expected nested delegation metadata error, got: %v", err)
}
deepCtx := withDelegationContext(context.Background(), "parent", 2)
_, err = manager.Spawn(deepCtx, "task-4", "four", "lookup", "synthesize", "cli", "chat", nil)
if err == nil || !strings.Contains(err.Error(), "delegation depth exceeded") {
t.Fatalf("expected depth error, got: %v", err)
}
close(block)
waitForCondition(t, 2*time.Second, func() bool {
for _, task := range manager.ListTasks() {
if task.Status == "running" {
return false
}
}
return true
})
}
func TestSubagentManager_ConcurrentSpawnRespectsFanout(t *testing.T) {
provider := &MockLanguageModel{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
manager.SetDelegationLimits(3, 2)
block := make(chan struct{})
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, _, _, _ string) (*ToolLoopResult, error) {
<-block
return &ToolLoopResult{Content: "done", Iterations: 1}, nil
})
const attempts = 10
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
successes := 0
fanoutErrors := 0
otherErrors := 0
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
_, err := manager.Spawn(context.Background(), fmt.Sprintf("task-%d", i), fmt.Sprintf("label-%d", i), "", "", "cli", "chat", nil)
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
return
}
if strings.Contains(err.Error(), "delegation fanout exceeded") {
fanoutErrors++
return
}
otherErrors++
}(i)
}
close(start)
wg.Wait()
mu.Lock()
assertSuccesses := successes
assertFanoutErrors := fanoutErrors
assertOtherErrors := otherErrors
mu.Unlock()
if assertSuccesses != 2 {
t.Fatalf("expected exactly 2 successful spawns, got %d", assertSuccesses)
}
if assertFanoutErrors != attempts-2 {
t.Fatalf("expected %d fanout errors, got %d", attempts-2, assertFanoutErrors)
}
if assertOtherErrors != 0 {
t.Fatalf("expected 0 non-fanout errors, got %d", assertOtherErrors)
}
close(block)
waitForCondition(t, 2*time.Second, func() bool {
for _, task := range manager.ListTasks() {
if task.Status == "running" {
return false
}
}
return true
})
}
func TestSubagentManager_SpawnAuditLineageAndRuntimeContext(t *testing.T) {
provider := &MockLanguageModel{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
var gotTaskID string
var gotDepth int
var gotChannel string
var gotChatID string
manager.SetRunLoop(func(ctx context.Context, _ ToolLoopConfig, _, _, channel, chatID string) (*ToolLoopResult, error) {
gotTaskID = delegationTaskIDFromContext(ctx)
gotDepth = delegationDepthFromContext(ctx)
gotChannel = channel
gotChatID = chatID
return &ToolLoopResult{Content: "delegated work complete", Iterations: 3}, nil
})
eventsCh := make(chan DelegationAuditEvent, 4)
manager.SetAuditHook(func(_ context.Context, evt DelegationAuditEvent) {
eventsCh <- evt
})
parentCtx := withDelegationContext(context.Background(), "parent-9", 1)
_, err := manager.Spawn(parentCtx, "task-a", "label-a", "collect facts", "final synthesis", "telegram", "chat-7", nil)
if err != nil {
t.Fatalf("spawn failed: %v", err)
}
var created *DelegationAuditEvent
var completed *DelegationAuditEvent
timeout := time.After(2 * time.Second)
for created == nil || completed == nil {
select {
case evt := <-eventsCh:
e := evt
switch evt.Status {
case "created":
created = &e
case "completed":
completed = &e
}
case <-timeout:
t.Fatal("timed out waiting for delegation audit events")
}
}
if created.ParentTaskID != "parent-9" {
t.Fatalf("expected parent task parent-9, got %s", created.ParentTaskID)
}
if created.Depth != 2 {
t.Fatalf("expected child depth 2, got %d", created.Depth)
}
if created.DelegatedScope != "collect facts" || created.KeptWork != "final synthesis" {
t.Fatalf("unexpected delegation metadata: %+v", *created)
}
if completed.TaskID != created.TaskID {
t.Fatalf("expected completion for created task %s, got %s", created.TaskID, completed.TaskID)
}
if completed.Iterations != 3 {
t.Fatalf("expected completion iterations=3, got %d", completed.Iterations)
}
if completed.ResultChars == 0 {
t.Fatal("expected completion to include non-zero result chars")
}
if gotTaskID != created.TaskID {
t.Fatalf("run loop context task id mismatch: got %s want %s", gotTaskID, created.TaskID)
}
if gotDepth != 2 {
t.Fatalf("run loop context depth mismatch: got %d want 2", gotDepth)
}
if gotChannel != "telegram" || gotChatID != "chat-7" {
t.Fatalf("run loop origin context mismatch: channel=%s chat=%s", gotChannel, gotChatID)
}
}

View file

@ -7,7 +7,7 @@ import (
"testing" "testing"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/ZanzyTHEbar/dragonscale/pkg/bus"
) )
// MockLanguageModel is a test implementation of fantasy.LanguageModel // MockLanguageModel is a test implementation of fantasy.LanguageModel
@ -123,6 +123,24 @@ func TestSubagentTool_Parameters(t *testing.T) {
t.Errorf("Label type should be 'string', got: %v", label["type"]) t.Errorf("Label type should be 'string', got: %v", label["type"])
} }
// Verify delegated_scope parameter
delegatedScope, ok := props["delegated_scope"].(map[string]interface{})
if !ok {
t.Fatal("delegated_scope parameter should exist")
}
if delegatedScope["type"] != "string" {
t.Errorf("delegated_scope type should be 'string', got: %v", delegatedScope["type"])
}
// Verify kept_work parameter
keptWork, ok := props["kept_work"].(map[string]interface{})
if !ok {
t.Fatal("kept_work parameter should exist")
}
if keptWork["type"] != "string" {
t.Errorf("kept_work type should be 'string', got: %v", keptWork["type"])
}
// Check required fields // Check required fields
required, ok := params["required"].([]string) required, ok := params["required"].([]string)
if !ok { if !ok {
@ -316,6 +334,52 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
// but execution success indicates context was handled properly // but execution success indicates context was handled properly
} }
func TestSubagentTool_Execute_NestedDelegationRequiresScopeAndKeptWork(t *testing.T) {
provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 1}, nil
})
tool := NewSubagentTool(manager)
// Simulate nested delegation (depth > 0) without delegated scope metadata.
ctx := withDelegationContext(context.Background(), "parent-task", 1)
result := tool.Execute(ctx, map[string]interface{}{
"task": "nested task",
"label": "nested",
})
if !result.IsError {
t.Fatal("Expected nested delegation without delegated_scope/kept_work to fail")
}
if !strings.Contains(result.ForLLM, "nested delegation requires delegated_scope and kept_work") {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
}
func TestSubagentTool_Execute_NestedDelegationWithMetadataSucceeds(t *testing.T) {
provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 2}, nil
})
tool := NewSubagentTool(manager)
ctx := withDelegationContext(context.Background(), "parent-task", 1)
result := tool.Execute(ctx, map[string]interface{}{
"task": "nested task",
"label": "nested",
"delegated_scope": "collect additional facts",
"kept_work": "final synthesis",
})
if result.IsError {
t.Fatalf("Expected nested delegation with metadata to succeed, got: %s", result.ForLLM)
}
}
// TestSubagentTool_ForUserTruncation verifies long content is truncated for user // TestSubagentTool_ForUserTruncation verifies long content is truncated for user
func TestSubagentTool_ForUserTruncation(t *testing.T) { func TestSubagentTool_ForUserTruncation(t *testing.T) {
provider := &MockLanguageModel{} provider := &MockLanguageModel{}

View file

@ -0,0 +1,20 @@
package tools
import (
"context"
"errors"
"testing"
)
func TestRunToolLoop_ReturnsContractError(t *testing.T) {
result, err := RunToolLoop(context.Background(), ToolLoopConfig{}, "", "", "", "")
if result != nil {
t.Fatalf("expected nil result when run loop is not configured, got %#v", result)
}
if err == nil {
t.Fatal("expected contract error from RunToolLoop fallback")
}
if !errors.Is(err, ErrRunLoopNotConfigured) {
t.Fatalf("expected ErrRunLoopNotConfigured, got: %v", err)
}
}