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,
"sections": len(sections),
"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,69 +3,150 @@ 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
Task string
Label string
OriginChannel string
OriginChatID string
Status string
Result string
Created int64
ID string
ParentTaskID string
Depth int
Task string
Label string
DelegatedScope string
KeptWork string
OriginChannel string
OriginChatID string
Status string
Result string
Created int64
}
// RunLoopFunc executes an agent tool loop. Injected from pkg/agent to break
// 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
model fantasy.LanguageModel
defaultModel string
bus *bus.MessageBus
workspace string
tools *ToolRegistry
maxIterations int
nextID int
runLoop RunLoopFunc
tasks map[string]*SubagentTask
mu sync.RWMutex
model fantasy.LanguageModel
defaultModel string
bus *bus.MessageBus
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 {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
model: model,
defaultModel: defaultModel,
bus: bus,
workspace: workspace,
tools: NewToolRegistry(),
maxIterations: 10,
nextID: 1,
tasks: make(map[string]*SubagentTask),
model: model,
defaultModel: defaultModel,
bus: bus,
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
return sm.runLoop
}
// 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,
Task: task,
Label: label,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: time.Now().UnixMilli(),
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{
Model: sm.model,
ModelID: sm.defaultModel,
Tools: tools,
Bus: sm.bus,
MaxIterations: maxIter,
}, systemPrompt, task.Task, task.OriginChannel, task.OriginChatID)
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)
}
}