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"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
"github.com/ZanzyTHEbar/dragonscale/pkg/memory"
"github.com/ZanzyTHEbar/dragonscale/pkg/memory/observation"
"github.com/ZanzyTHEbar/dragonscale/pkg/messages"
"github.com/ZanzyTHEbar/dragonscale/pkg/skills"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
)
type ContextBuilder struct {
@ -37,7 +38,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
primarySkillsDir = dir
}
// Global skills: ~/.config/picoclaw/skills (user-level overrides).
// Global skills: ~/.config/dragonscale/skills (user-level overrides).
globalSkillsDir := ""
if dir, err := config.ConfigDir(); err == nil {
globalSkillsDir = filepath.Join(dir, "skills")
@ -99,9 +100,9 @@ func (cb *ContextBuilder) getIdentity() string {
// Build tools section dynamically
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
%s
@ -153,12 +154,6 @@ func (cb *ContextBuilder) buildToolsSection() 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 {
type section struct {
name string
@ -183,8 +178,8 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
The following skills extend your capabilities. To use a skill:
1. Use **skill_search** to find relevant skills by keyword
2. Use **skill_read** via tool_call to load the full skill content
3. Use **skill_traverse** via tool_call to explore related skills
2. Call **skill_read** directly to load the full skill content
3. Call **skill_traverse** directly to explore related skills
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
// system prompt, trim lowest-priority sections first.
budgetChars := cb.tokenBudgetChars()
totalChars := 0
for _, s := range sections {
totalChars += len(s.content)
budgetTokens := cb.tokenBudgetTokens()
totalTokens := 0
sectionTokens := make([]int, len(sections))
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",
map[string]interface{}{
"total_chars": totalChars,
"budget_chars": budgetChars,
"total_tokens": totalTokens,
"budget_tokens": budgetTokens,
"sections": len(sections),
})
// 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)
totalChars -= len(sections[i].content)
totalTokens -= sectionTokens[i]
sections[i].content = ""
}
}
@ -247,7 +244,7 @@ Do NOT assume skill content — always load before applying.
prompt := strings.Join(parts, "\n\n---\n\n")
// Log token estimate for observability
tokenEst := len(prompt) / charsPerToken
tokenEst := observation.EstimateTokens(prompt)
logger.DebugCF("context", "System prompt token estimate",
map[string]interface{}{
"chars": len(prompt),
@ -258,14 +255,14 @@ Do NOT assume skill content — always load before applying.
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.
func (cb *ContextBuilder) tokenBudgetChars() int {
func (cb *ContextBuilder) tokenBudgetTokens() int {
if cb.contextWindow <= 0 {
return 0
}
// 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 {
@ -276,7 +273,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "picoclaw", "bootstrap")
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "dragonscale", "bootstrap")
if err != nil || len(docs) == 0 {
return ""
}
@ -302,7 +299,7 @@ func (cb *ContextBuilder) buildWorkingContextSection() string {
var parts []string
// 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 != "" {
parts = append(parts, "## Working Context\n\n"+wc)
}

View file

@ -9,9 +9,9 @@ import (
"time"
fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
)
// --- 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 (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
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.
func mustNewAgentLoop(t *testing.T, cfg *config.Config, msgBus *bus.MessageBus, model fantasy.LanguageModel) *AgentLoop {
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)
if err != nil {
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) 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) {
// Create temp workspace
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
func TestToolRegistry_ToolRegistration(t *testing.T) {
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)
}
}
// 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
// the database path, embedding dimensions, offloading threshold, and sync.
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.
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.
// 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
// 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 EmbeddingConfig `json:"embedding"`
@ -85,21 +85,21 @@ type MemoryConfig struct {
type EmbeddingConfig struct {
// Provider selects the embedding backend: "ollama", "openai", or "".
// 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").
// 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.
// For Ollama defaults to "http://localhost:11434".
// For OpenAI defaults to "https://api.openai.com/v1".
// 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.
// 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.
@ -107,38 +107,57 @@ type EmbeddingConfig struct {
type MemorySyncConfig struct {
// SyncURL is the Turso primary database URL (e.g., "libsql://mydb.turso.io").
// 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 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).
// 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.
// 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 {
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 {
// Sandbox is the directory for agent file operations (tools sandbox).
// Defaults to $XDG_DATA_HOME/picoclaw/sandbox when empty.
Sandbox string `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"`
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
// Defaults to $XDG_DATA_HOME/dragonscale/sandbox when empty.
Sandbox string `json:"sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_SANDBOX"`
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
Provider string `json:"provider" env:"DRAGONSCALE_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"DRAGONSCALE_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"DRAGONSCALE_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"DRAGONSCALE_AGENTS_DEFAULTS_TEMPERATURE"`
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.
Workspace string `json:"workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Workspace string `json:"workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
}
type ChannelsConfig struct {
@ -155,88 +174,88 @@ type ChannelsConfig struct {
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"DRAGONSCALE_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"DRAGONSCALE_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"DRAGONSCALE_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"DRAGONSCALE_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"DRAGONSCALE_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"DRAGONSCALE_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DISCORD_ALLOW_FROM"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"DRAGONSCALE_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"DRAGONSCALE_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"DRAGONSCALE_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"DRAGONSCALE_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"DRAGONSCALE_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"DRAGONSCALE_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"DRAGONSCALE_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"DRAGONSCALE_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
Enabled bool `json:"enabled" env:"DRAGONSCALE_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"DRAGONSCALE_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"DRAGONSCALE_DEVICES_MONITOR_USB"`
}
type ProvidersConfig struct {
@ -255,40 +274,70 @@ type ProvidersConfig struct {
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 {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitzero" env:"PICOCLAW_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`
APIKey string `json:"api_key" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
ConnectMode string `json:"connect_mode,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
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 {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
Host string `json:"host" env:"DRAGONSCALE_GATEWAY_HOST"`
Port int `json:"port" env:"DRAGONSCALE_GATEWAY_PORT"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type WebToolsConfig struct {
@ -298,7 +347,7 @@ type WebToolsConfig 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 {
@ -317,6 +366,12 @@ func DefaultConfig() *Config {
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
ContinuityRetention: ContinuityRetentionConfig{
MinMessages: 4,
MaxMessages: 24,
TargetContextRatio: 0.10,
FailureKeepMessages: 10,
},
},
},
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))
}
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 {
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 {
return dir
}
return expandHome("~/.local/share/picoclaw/sandbox")
return expandHome("~/.local/share/dragonscale/sandbox")
}
// RestrictToSandbox returns whether tool file operations should be restricted
@ -602,7 +674,7 @@ func (c *Config) DBPath() string {
if p, err := DefaultDBPath(); err == nil {
return p
}
return expandHome("~/.local/share/picoclaw/picoclaw.db")
return expandHome("~/.local/share/dragonscale/dragonscale.db")
}
func (c *Config) GetAPIKey() string {
@ -669,12 +741,12 @@ func expandHome(path string) string {
// ─── XDG / platform path helpers ─────────────────────────────────────────────
const appName = "picoclaw"
const appName = "dragonscale"
// ConfigDir returns the platform-appropriate user configuration directory for
// picoclaw, following XDG Base Directory spec on Linux
// (~/.config/picoclaw), Library/Application Support on macOS, and
// %AppData%\picoclaw on Windows. The directory is created if it does not exist.
// dragonscale, following XDG Base Directory spec on Linux
// (~/.config/dragonscale), Library/Application Support on macOS, and
// %AppData%\dragonscale on Windows. The directory is created if it does not exist.
func ConfigDir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
@ -687,10 +759,10 @@ func ConfigDir() (string, error) {
return dir, nil
}
// DataDir returns the platform-appropriate user data directory for picoclaw.
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/picoclaw).
// On macOS it uses ~/Library/Application Support/picoclaw; on Windows
// %LOCALAPPDATA%\picoclaw. The directory is created if it does not exist.
// DataDir returns the platform-appropriate user data directory for dragonscale.
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/dragonscale).
// On macOS it uses ~/Library/Application Support/dragonscale; on Windows
// %LOCALAPPDATA%\dragonscale. The directory is created if it does not exist.
func DataDir() (string, error) {
var base string
switch runtime.GOOS {
@ -765,8 +837,8 @@ func SandboxDir() (string, error) {
return dir, nil
}
// CacheDir returns the platform-appropriate user cache directory for picoclaw
// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if
// CacheDir returns the platform-appropriate user cache directory for dragonscale
// (XDG_CACHE_HOME on Linux → ~/.cache/dragonscale). The directory is created if
// it does not exist.
func CacheDir() (string, error) {
base, err := os.UserCacheDir()
@ -782,7 +854,7 @@ func CacheDir() (string, error) {
// DefaultDBPath returns the canonical SQLite database path inside DataDir.
// 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) {
dataDir, err := DataDir()
if err != nil {
@ -792,7 +864,7 @@ func DefaultDBPath() (string, error) {
}
// DefaultConfigPath returns the path to the primary JSON config file inside
// ConfigDir (picoclaw/config.json).
// ConfigDir (dragonscale/config.json).
func DefaultConfigPath() (string, error) {
cfgDir, err := ConfigDir()
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
func TestDefaultConfig_Temperature(t *testing.T) {
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 {
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,17 +3,22 @@ package tools
import (
"context"
"fmt"
"strings"
"sync"
"time"
fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
)
type SubagentTask struct {
ID string
ParentTaskID string
Depth int
Task string
Label string
DelegatedScope string
KeptWork string
OriginChannel string
OriginChatID string
Status string
@ -25,6 +30,50 @@ type SubagentTask struct {
// 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 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 {
tasks map[string]*SubagentTask
mu sync.RWMutex
@ -34,8 +83,12 @@ type SubagentManager struct {
workspace string
tools *ToolRegistry
maxIterations int
maxDepth int
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 {
@ -47,25 +100,53 @@ func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace str
workspace: workspace,
tools: NewToolRegistry(),
maxIterations: 10,
maxDepth: 3,
maxFanout: 4,
activeChildren: make(map[string]int),
nextID: 1,
}
}
// 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) {
sm.mu.Lock()
defer sm.mu.Unlock()
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 {
sm.mu.RLock()
defer sm.mu.RUnlock()
if sm.runLoop != nil {
return sm.runLoop
}
return RunToolLoop
}
// SetTools sets the tool registry for subagent execution.
@ -82,23 +163,66 @@ func (sm *SubagentManager) RegisterTool(tool 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()
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)
sm.nextID++
sm.activeChildren[parentTaskID]++
subagentTask := &SubagentTask{
ID: taskID,
ParentTaskID: parentTaskID,
Depth: childDepth,
Task: task,
Label: label,
DelegatedScope: delegatedScope,
KeptWork: keptWork,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: time.Now().UnixMilli(),
}
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
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) {
task.Status = "running"
task.Created = time.Now().UnixMilli()
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
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.`
systemPrompt := `You are a subagent operating under the same runtime discipline as the main agent.
Use tools for actions. Do not claim actions without tool execution.
When discovering tools, call discovered tools directly; use tool_call only as fallback.
Complete the task independently and provide a clear summary of what was done.`
// Check if context is already cancelled before starting
select {
@ -135,18 +257,51 @@ After completing the task, provide a clear summary of what was done.`
sm.mu.RUnlock()
runLoop := sm.getRunLoop()
loopResult, err := runLoop(ctx, ToolLoopConfig{
var loopResult *ToolLoopResult
var err error
if runLoop == nil {
err = ErrRunLoopNotConfigured
} else {
taskCtx := withDelegationContext(ctx, task.ID, task.Depth)
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()
var result *ToolResult
iterations := 0
resultChars := 0
errText := ""
finalStatus := task.Status
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.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 {
callback(ctx, result)
}
@ -155,6 +310,7 @@ After completing the task, provide a clear summary of what was done.`
if err != nil {
task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err)
errText = err.Error()
if ctx.Err() != nil {
task.Status = "cancelled"
task.Result = "Task cancelled during execution"
@ -170,6 +326,7 @@ After completing the task, provide a clear summary of what was done.`
} else {
task.Status = "completed"
task.Result = loopResult.Content
iterations = loopResult.Iterations
result = &ToolResult{
ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
ForUser: loopResult.Content,
@ -195,7 +352,11 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
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 {
@ -204,7 +365,11 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
tasks := make([]*SubagentTask, 0, len(sm.tasks))
for _, task := range sm.tasks {
tasks = append(tasks, task)
if task == nil {
continue
}
copied := *task
tasks = append(tasks, &copied)
}
return tasks
}
@ -244,6 +409,14 @@ func (t *SubagentTool) Parameters() map[string]interface{} {
"type": "string",
"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"},
}
@ -261,21 +434,73 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
}
label, _ := args["label"].(string)
delegatedScope, _ := args["delegated_scope"].(string)
keptWork, _ := args["kept_work"].(string)
if t.manager == 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
parentTaskID := delegationTaskIDFromContext(ctx)
if parentTaskID == "" {
parentTaskID = "root"
}
parentDepth := delegationDepthFromContext(ctx)
childDepth := parentDepth + 1
sm.mu.RLock()
tools := sm.tools
maxIter := sm.maxIterations
maxDepth := sm.maxDepth
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()
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,
ModelID: sm.defaultModel,
Tools: tools,
@ -284,6 +509,19 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
}, systemPrompt, task, t.originChannel, t.originChatID)
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)
}
@ -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",
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{
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"
fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
)
// 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"])
}
// 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
required, ok := params["required"].([]string)
if !ok {
@ -316,6 +334,52 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
// 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
func TestSubagentTool_ForUserTruncation(t *testing.T) {
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)
}
}