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:
parent
6ad5c112b2
commit
5ca4a8c6da
11 changed files with 1777 additions and 399 deletions
|
|
@ -9,12 +9,13 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/memory"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/memory"
|
||||||
"github.com/sipeed/picoclaw/pkg/messages"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/memory/observation"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/messages"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/skills"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
|
|
@ -37,7 +38,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
primarySkillsDir = dir
|
primarySkillsDir = dir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global skills: ~/.config/picoclaw/skills (user-level overrides).
|
// Global skills: ~/.config/dragonscale/skills (user-level overrides).
|
||||||
globalSkillsDir := ""
|
globalSkillsDir := ""
|
||||||
if dir, err := config.ConfigDir(); err == nil {
|
if dir, err := config.ConfigDir(); err == nil {
|
||||||
globalSkillsDir = filepath.Join(dir, "skills")
|
globalSkillsDir = filepath.Join(dir, "skills")
|
||||||
|
|
@ -99,9 +100,9 @@ func (cb *ContextBuilder) getIdentity() string {
|
||||||
// Build tools section dynamically
|
// Build tools section dynamically
|
||||||
toolsSection := cb.buildToolsSection()
|
toolsSection := cb.buildToolsSection()
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(`# dragonscale 🦞
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are dragonscale, a helpful AI assistant.
|
||||||
|
|
||||||
## Current Time
|
## Current Time
|
||||||
%s
|
%s
|
||||||
|
|
@ -153,12 +154,6 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// roughTokenEstimate gives a conservative char-to-token ratio for budget checks.
|
|
||||||
// ~4 chars per token for English text is a standard heuristic.
|
|
||||||
// FIXME: This is a rough estimate and may not be accurate for all languages.
|
|
||||||
// FIXME: Implement a proper token estimator.
|
|
||||||
const charsPerToken = 4
|
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
type section struct {
|
type section struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -183,8 +178,8 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
|
|
||||||
The following skills extend your capabilities. To use a skill:
|
The following skills extend your capabilities. To use a skill:
|
||||||
1. Use **skill_search** to find relevant skills by keyword
|
1. Use **skill_search** to find relevant skills by keyword
|
||||||
2. Use **skill_read** via tool_call to load the full skill content
|
2. Call **skill_read** directly to load the full skill content
|
||||||
3. Use **skill_traverse** via tool_call to explore related skills
|
3. Call **skill_traverse** directly to explore related skills
|
||||||
|
|
||||||
Do NOT assume skill content — always load before applying.
|
Do NOT assume skill content — always load before applying.
|
||||||
|
|
||||||
|
|
@ -215,23 +210,25 @@ Do NOT assume skill content — always load before applying.
|
||||||
|
|
||||||
// Token budget enforcement: if we exceed ~40% of context window for the
|
// Token budget enforcement: if we exceed ~40% of context window for the
|
||||||
// system prompt, trim lowest-priority sections first.
|
// system prompt, trim lowest-priority sections first.
|
||||||
budgetChars := cb.tokenBudgetChars()
|
budgetTokens := cb.tokenBudgetTokens()
|
||||||
totalChars := 0
|
totalTokens := 0
|
||||||
for _, s := range sections {
|
sectionTokens := make([]int, len(sections))
|
||||||
totalChars += len(s.content)
|
for i, s := range sections {
|
||||||
|
sectionTokens[i] = observation.EstimateTokens(s.content)
|
||||||
|
totalTokens += sectionTokens[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if budgetChars > 0 && totalChars > budgetChars {
|
if budgetTokens > 0 && totalTokens > budgetTokens {
|
||||||
logger.WarnCF("context", "System prompt exceeds token budget, trimming low-priority sections",
|
logger.WarnCF("context", "System prompt exceeds token budget, trimming low-priority sections",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"total_chars": totalChars,
|
"total_tokens": totalTokens,
|
||||||
"budget_chars": budgetChars,
|
"budget_tokens": budgetTokens,
|
||||||
"sections": len(sections),
|
"sections": len(sections),
|
||||||
})
|
})
|
||||||
// Trim from lowest priority (highest number) first
|
// Trim from lowest priority (highest number) first
|
||||||
for i := len(sections) - 1; i >= 0 && totalChars > budgetChars; i-- {
|
for i := len(sections) - 1; i >= 0 && totalTokens > budgetTokens; i-- {
|
||||||
if sections[i].priority >= 5 { // only trim P5+ (knowledge, dag)
|
if sections[i].priority >= 5 { // only trim P5+ (knowledge, dag)
|
||||||
totalChars -= len(sections[i].content)
|
totalTokens -= sectionTokens[i]
|
||||||
sections[i].content = ""
|
sections[i].content = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +244,7 @@ Do NOT assume skill content — always load before applying.
|
||||||
prompt := strings.Join(parts, "\n\n---\n\n")
|
prompt := strings.Join(parts, "\n\n---\n\n")
|
||||||
|
|
||||||
// Log token estimate for observability
|
// Log token estimate for observability
|
||||||
tokenEst := len(prompt) / charsPerToken
|
tokenEst := observation.EstimateTokens(prompt)
|
||||||
logger.DebugCF("context", "System prompt token estimate",
|
logger.DebugCF("context", "System prompt token estimate",
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"chars": len(prompt),
|
"chars": len(prompt),
|
||||||
|
|
@ -258,14 +255,14 @@ Do NOT assume skill content — always load before applying.
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenBudgetChars returns the maximum character count for the system prompt,
|
// tokenBudgetTokens returns the maximum token count for the system prompt,
|
||||||
// derived from the context window size. Returns 0 if no limit is configured.
|
// derived from the context window size. Returns 0 if no limit is configured.
|
||||||
func (cb *ContextBuilder) tokenBudgetChars() int {
|
func (cb *ContextBuilder) tokenBudgetTokens() int {
|
||||||
if cb.contextWindow <= 0 {
|
if cb.contextWindow <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Reserve ~40% of context window for system prompt
|
// Reserve ~40% of context window for system prompt
|
||||||
return int(float64(cb.contextWindow) * 0.4 * charsPerToken)
|
return int(float64(cb.contextWindow) * 0.4)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
|
|
@ -276,7 +273,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "picoclaw", "bootstrap")
|
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "dragonscale", "bootstrap")
|
||||||
if err != nil || len(docs) == 0 {
|
if err != nil || len(docs) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +299,7 @@ func (cb *ContextBuilder) buildWorkingContextSection() string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
// Inject working context (hot tier)
|
// Inject working context (hot tier)
|
||||||
wc, err := cb.memoryStore.GetWorkingContext(ctx, "picoclaw", "default")
|
wc, err := cb.memoryStore.GetWorkingContext(ctx, "dragonscale", "default")
|
||||||
if err == nil && wc != "" {
|
if err == nil && wc != "" {
|
||||||
parts = append(parts, "## Working Context\n\n"+wc)
|
parts = append(parts, "## Working Context\n\n"+wc)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fantasy "charm.land/fantasy"
|
fantasy "charm.land/fantasy"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock language model that simulates tool calls ---
|
// --- Mock language model that simulates tool calls ---
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,20 +2,29 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fantasy "charm.land/fantasy"
|
fantasy "charm.land/fantasy"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/messages"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mustNewAgentLoop wraps NewAgentLoop and fails the test on error.
|
// mustNewAgentLoop wraps NewAgentLoop and fails the test on error.
|
||||||
func mustNewAgentLoop(t *testing.T, cfg *config.Config, msgBus *bus.MessageBus, model fantasy.LanguageModel) *AgentLoop {
|
func mustNewAgentLoop(t *testing.T, cfg *config.Config, msgBus *bus.MessageBus, model fantasy.LanguageModel) *AgentLoop {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
if cfg != nil && cfg.Memory.DBPath == "" && strings.TrimSpace(cfg.Agents.Defaults.Workspace) != "" {
|
||||||
|
cfg.Memory.DBPath = filepath.Join(cfg.Agents.Defaults.Workspace, "agent-loop-test.db")
|
||||||
|
}
|
||||||
al, err := NewAgentLoop(context.Background(), cfg, msgBus, model)
|
al, err := NewAgentLoop(context.Background(), cfg, msgBus, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewAgentLoop: %v", err)
|
t.Fatalf("NewAgentLoop: %v", err)
|
||||||
|
|
@ -62,6 +71,125 @@ func (m *mockLanguageModel) StreamObject(_ context.Context, _ fantasy.ObjectCall
|
||||||
func (m *mockLanguageModel) Provider() string { return "mock" }
|
func (m *mockLanguageModel) Provider() string { return "mock" }
|
||||||
func (m *mockLanguageModel) Model() string { return "mock-model" }
|
func (m *mockLanguageModel) Model() string { return "mock-model" }
|
||||||
|
|
||||||
|
func TestContinuityKeepCount_UsesConfiguredPolicy(t *testing.T) {
|
||||||
|
buildHistory := func(n int, content string) []messages.Message {
|
||||||
|
history := make([]messages.Message, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
role := "user"
|
||||||
|
if i%2 == 1 {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
history = append(history, messages.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.MinMessages = 3
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.MaxMessages = 7
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0.01
|
||||||
|
|
||||||
|
al := &AgentLoop{
|
||||||
|
cfg: cfg,
|
||||||
|
contextWindow: 256,
|
||||||
|
}
|
||||||
|
|
||||||
|
history := buildHistory(24, strings.Repeat("long message token payload ", 12))
|
||||||
|
keepSmallBudget := al.continuityKeepCount(history)
|
||||||
|
if keepSmallBudget != 3 {
|
||||||
|
t.Fatalf("expected keep count to respect min_messages=3 under tight budget, got %d", keepSmallBudget)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0.40
|
||||||
|
al.contextWindow = 8192
|
||||||
|
keepLargeBudget := al.continuityKeepCount(history)
|
||||||
|
if keepLargeBudget != 7 {
|
||||||
|
t.Fatalf("expected keep count to cap at max_messages=7 under large budget, got %d", keepLargeBudget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareRuntimeState_ConcurrentSameSessionUsesSingleConversation(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
model := newMockLanguageModel("")
|
||||||
|
al := mustNewAgentLoop(t, cfg, msgBus, model)
|
||||||
|
beforeConversations, err := al.queries.ListAgentConversations(context.Background(), memsqlc.ListAgentConversationsParams{
|
||||||
|
Limit: 10000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAgentConversations (before) failed: %v", err)
|
||||||
|
}
|
||||||
|
beforeCount := len(beforeConversations)
|
||||||
|
|
||||||
|
const workers = 12
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
conversationIDs := make(chan string, workers)
|
||||||
|
errorsCh := make(chan error, workers)
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
conversationID, _, prepareErr := al.prepareRuntimeState(context.Background(), "race-session")
|
||||||
|
if prepareErr != nil {
|
||||||
|
errorsCh <- prepareErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conversationIDs <- conversationID.String()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(errorsCh)
|
||||||
|
close(conversationIDs)
|
||||||
|
|
||||||
|
for prepareErr := range errorsCh {
|
||||||
|
if prepareErr != nil {
|
||||||
|
t.Fatalf("unexpected prepareRuntimeState error: %v", prepareErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueConversationIDs := make(map[string]struct{})
|
||||||
|
for id := range conversationIDs {
|
||||||
|
uniqueConversationIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(uniqueConversationIDs) != 1 {
|
||||||
|
t.Fatalf("expected one conversation id, got %d (%v)", len(uniqueConversationIDs), uniqueConversationIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations, err := al.queries.ListAgentConversations(context.Background(), memsqlc.ListAgentConversationsParams{
|
||||||
|
Limit: 10000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAgentConversations failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(conversations) != beforeCount+1 {
|
||||||
|
t.Fatalf("expected conversation count delta +1, got before=%d after=%d", beforeCount, len(conversations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
// Create temp workspace
|
// Create temp workspace
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -183,6 +311,36 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentLoop_UnifiedKernelDependenciesInitialized(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
model := newMockLanguageModel("")
|
||||||
|
al := mustNewAgentLoop(t, cfg, msgBus, model)
|
||||||
|
|
||||||
|
if !al.HasSecureBus() {
|
||||||
|
t.Fatal("Expected secure bus to be configured")
|
||||||
|
}
|
||||||
|
if !al.HasUnifiedRuntimeDeps() {
|
||||||
|
t.Fatal("Expected unified runtime dependencies to be configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved
|
// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved
|
||||||
func TestToolRegistry_ToolRegistration(t *testing.T) {
|
func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -613,3 +771,161 @@ func TestResolveFinalContent_RecoversFromToolResultText(t *testing.T) {
|
||||||
t.Fatalf("expected tool result text, got %q", got)
|
t.Fatalf("expected tool result text, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestForceCompression_PersistsProvenance verifies that emergency compression
|
||||||
|
// cycles persist provenance metadata to the audit log for postmortem.
|
||||||
|
func TestForceCompression_PersistsProvenance(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-provenance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Small context window so we can exceed 95% threshold with modest history
|
||||||
|
// 1000 * 0.95 = 950 tokens; estimateTokens = chars*2/5, so need chars > 2375
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 1000,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContinuityRetention: config.ContinuityRetentionConfig{
|
||||||
|
MinMessages: 3,
|
||||||
|
MaxMessages: 8,
|
||||||
|
TargetContextRatio: 0.05,
|
||||||
|
FailureKeepMessages: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
model := newMockLanguageModel("Summary of conversation.")
|
||||||
|
al := mustNewAgentLoop(t, cfg, msgBus, model)
|
||||||
|
al.contextWindow = 1000
|
||||||
|
sessionKey := "provenance-test-session"
|
||||||
|
|
||||||
|
// Seed history to exceed critical threshold by a wide margin so this test
|
||||||
|
// stays deterministic across tokenizer/estimator behavior changes.
|
||||||
|
const charsPerMsg = 900
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
content := fmt.Sprintf("user message %d: %s", i, strings.Repeat("x", charsPerMsg-20))
|
||||||
|
al.sessions.AddMessage(sessionKey, "user", content)
|
||||||
|
al.sessions.AddMessage(sessionKey, "assistant", "short reply")
|
||||||
|
}
|
||||||
|
al.sessions.Save(sessionKey)
|
||||||
|
history := al.sessions.GetHistory(sessionKey)
|
||||||
|
keep := al.continuityKeepCount(history)
|
||||||
|
if len(history) <= keep {
|
||||||
|
t.Fatalf("test precondition failed: history=%d keep=%d", len(history), keep)
|
||||||
|
}
|
||||||
|
tokenEstimate := al.estimateTokens(history)
|
||||||
|
criticalThreshold := al.contextWindow * 95 / 100
|
||||||
|
if tokenEstimate <= criticalThreshold {
|
||||||
|
t.Fatalf("test precondition failed: token_estimate=%d threshold=%d", tokenEstimate, criticalThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
al.forceCompression(ctx, sessionKey)
|
||||||
|
|
||||||
|
del := al.MemoryDelegate()
|
||||||
|
if del == nil {
|
||||||
|
t.Fatal("MemoryDelegate is nil")
|
||||||
|
}
|
||||||
|
entries, err := del.ListAuditEntriesByAction(ctx, "dragonscale", "emergency_compression", 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAuditEntriesByAction: %v", err)
|
||||||
|
}
|
||||||
|
matching := make([]EmergencyProvenance, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
var prov EmergencyProvenance
|
||||||
|
if err := json.Unmarshal([]byte(entry.Input), &prov); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prov.SessionKey == sessionKey {
|
||||||
|
matching = append(matching, prov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matching) == 0 {
|
||||||
|
t.Fatal("Expected at least one emergency_compression audit entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata shape: session_key, cycle, token_estimate, critical_budget
|
||||||
|
prov := matching[0]
|
||||||
|
if prov.SessionKey != sessionKey {
|
||||||
|
t.Errorf("session_key: want %q, got %q", sessionKey, prov.SessionKey)
|
||||||
|
}
|
||||||
|
if prov.Cycle < 1 || prov.Cycle > 3 {
|
||||||
|
t.Errorf("cycle: want 1..3, got %d", prov.Cycle)
|
||||||
|
}
|
||||||
|
if prov.TokenEstimate <= 0 {
|
||||||
|
t.Errorf("token_estimate: want > 0, got %d", prov.TokenEstimate)
|
||||||
|
}
|
||||||
|
if prov.CriticalBudget != 950 {
|
||||||
|
t.Errorf("critical_budget: want 950, got %d", prov.CriticalBudget)
|
||||||
|
}
|
||||||
|
if prov.HistoryMsgCount < 8 {
|
||||||
|
t.Errorf("history_msg_count: want >= 8, got %d", prov.HistoryMsgCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistOversizedRecoveryRefs_CreatesRecoverableReferences(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-recovery-ref-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 2048,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
model := newMockLanguageModel("ok")
|
||||||
|
al := mustNewAgentLoop(t, cfg, msgBus, model)
|
||||||
|
|
||||||
|
omitted := []oversizedRecoveryCandidate{
|
||||||
|
{
|
||||||
|
Message: messages.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "omitted oversized content for recovery",
|
||||||
|
},
|
||||||
|
OriginalIndex: 2,
|
||||||
|
TokenEstimate: 9999,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refs, err := al.persistOversizedRecoveryRefs(context.Background(), "recovery-session", omitted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("persistOversizedRecoveryRefs failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("expected one recovery ref, got %d", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
dagTool := tools.NewDagExpandTool(tools.DAGToolDeps{
|
||||||
|
Delegate: al.MemoryDelegate(),
|
||||||
|
AgentID: "dragonscale",
|
||||||
|
SessionFn: func() string {
|
||||||
|
return "recovery-session"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res := dagTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"node_id": refs[0],
|
||||||
|
"session_key": "recovery-session",
|
||||||
|
})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("expected recovery ref expansion to succeed, got: %s", res.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.ForLLM, "omitted oversized content for recovery") {
|
||||||
|
t.Fatalf("expected recovered content in output, got: %s", res.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,17 @@ type Config struct {
|
||||||
// Memory is always enabled; there is no opt-out. Configuration controls
|
// Memory is always enabled; there is no opt-out. Configuration controls
|
||||||
// the database path, embedding dimensions, offloading threshold, and sync.
|
// the database path, embedding dimensions, offloading threshold, and sync.
|
||||||
type MemoryConfig struct {
|
type MemoryConfig struct {
|
||||||
// DBPath overrides the default database path (workspace/memory/picoclaw.db).
|
// DBPath overrides the default database path (workspace/memory/dragonscale.db).
|
||||||
// Empty string uses the default.
|
// Empty string uses the default.
|
||||||
DBPath string `json:"db_path" env:"PICOCLAW_MEMORY_DB_PATH"`
|
DBPath string `json:"db_path" env:"DRAGONSCALE_MEMORY_DB_PATH"`
|
||||||
|
|
||||||
// EmbeddingDims is the vector dimensionality for archival embeddings.
|
// EmbeddingDims is the vector dimensionality for archival embeddings.
|
||||||
// Default: 768 (sentence-transformers). Use 1536 for OpenAI ada-002, 384 for MiniLM.
|
// Default: 768 (sentence-transformers). Use 1536 for OpenAI ada-002, 384 for MiniLM.
|
||||||
EmbeddingDims int `json:"embedding_dims" env:"PICOCLAW_MEMORY_EMBEDDING_DIMS"`
|
EmbeddingDims int `json:"embedding_dims" env:"DRAGONSCALE_MEMORY_EMBEDDING_DIMS"`
|
||||||
|
|
||||||
// OffloadThresholdTokens is the token count above which tool results
|
// OffloadThresholdTokens is the token count above which tool results
|
||||||
// are automatically offloaded to archival memory. Default: 4000.
|
// are automatically offloaded to archival memory. Default: 4000.
|
||||||
OffloadThresholdTokens int `json:"offload_threshold_tokens" env:"PICOCLAW_MEMORY_OFFLOAD_THRESHOLD_TOKENS"`
|
OffloadThresholdTokens int `json:"offload_threshold_tokens" env:"DRAGONSCALE_MEMORY_OFFLOAD_THRESHOLD_TOKENS"`
|
||||||
|
|
||||||
// Embedding configures the embedding provider for archival vector search.
|
// Embedding configures the embedding provider for archival vector search.
|
||||||
Embedding EmbeddingConfig `json:"embedding"`
|
Embedding EmbeddingConfig `json:"embedding"`
|
||||||
|
|
@ -85,21 +85,21 @@ type MemoryConfig struct {
|
||||||
type EmbeddingConfig struct {
|
type EmbeddingConfig struct {
|
||||||
// Provider selects the embedding backend: "ollama", "openai", or "".
|
// Provider selects the embedding backend: "ollama", "openai", or "".
|
||||||
// Empty string disables embeddings (FTS5-only search).
|
// Empty string disables embeddings (FTS5-only search).
|
||||||
Provider string `json:"provider" env:"PICOCLAW_MEMORY_EMBEDDING_PROVIDER"`
|
Provider string `json:"provider" env:"DRAGONSCALE_MEMORY_EMBEDDING_PROVIDER"`
|
||||||
|
|
||||||
// Model is the embedding model name (e.g., "nomic-embed-text", "text-embedding-3-small").
|
// Model is the embedding model name (e.g., "nomic-embed-text", "text-embedding-3-small").
|
||||||
// Defaults depend on provider: "nomic-embed-text" for Ollama, "text-embedding-3-small" for OpenAI.
|
// Defaults depend on provider: "nomic-embed-text" for Ollama, "text-embedding-3-small" for OpenAI.
|
||||||
Model string `json:"model" env:"PICOCLAW_MEMORY_EMBEDDING_MODEL"`
|
Model string `json:"model" env:"DRAGONSCALE_MEMORY_EMBEDDING_MODEL"`
|
||||||
|
|
||||||
// APIBase overrides the provider's API base URL.
|
// APIBase overrides the provider's API base URL.
|
||||||
// For Ollama defaults to "http://localhost:11434".
|
// For Ollama defaults to "http://localhost:11434".
|
||||||
// For OpenAI defaults to "https://api.openai.com/v1".
|
// For OpenAI defaults to "https://api.openai.com/v1".
|
||||||
// Empty string uses the default for the selected provider.
|
// Empty string uses the default for the selected provider.
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_MEMORY_EMBEDDING_API_BASE"`
|
APIBase string `json:"api_base" env:"DRAGONSCALE_MEMORY_EMBEDDING_API_BASE"`
|
||||||
|
|
||||||
// APIKey for the embedding provider. Required for OpenAI, optional for Ollama.
|
// APIKey for the embedding provider. Required for OpenAI, optional for Ollama.
|
||||||
// If empty, falls back to the matching provider's key from providers config.
|
// If empty, falls back to the matching provider's key from providers config.
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_MEMORY_EMBEDDING_API_KEY"`
|
APIKey string `json:"api_key" env:"DRAGONSCALE_MEMORY_EMBEDDING_API_KEY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemorySyncConfig configures Turso embedded replica synchronization.
|
// MemorySyncConfig configures Turso embedded replica synchronization.
|
||||||
|
|
@ -107,38 +107,57 @@ type EmbeddingConfig struct {
|
||||||
type MemorySyncConfig struct {
|
type MemorySyncConfig struct {
|
||||||
// SyncURL is the Turso primary database URL (e.g., "libsql://mydb.turso.io").
|
// SyncURL is the Turso primary database URL (e.g., "libsql://mydb.turso.io").
|
||||||
// Empty string disables replication (local-only mode).
|
// Empty string disables replication (local-only mode).
|
||||||
SyncURL string `json:"sync_url" env:"PICOCLAW_MEMORY_SYNC_URL"`
|
SyncURL string `json:"sync_url" env:"DRAGONSCALE_MEMORY_SYNC_URL"`
|
||||||
|
|
||||||
// AuthToken is the Turso authentication token for the remote database.
|
// AuthToken is the Turso authentication token for the remote database.
|
||||||
AuthToken string `json:"auth_token" env:"PICOCLAW_MEMORY_SYNC_AUTH_TOKEN"`
|
AuthToken string `json:"auth_token" env:"DRAGONSCALE_MEMORY_SYNC_AUTH_TOKEN"`
|
||||||
|
|
||||||
// SyncIntervalSeconds is how often to sync with the remote primary (in seconds).
|
// SyncIntervalSeconds is how often to sync with the remote primary (in seconds).
|
||||||
// Zero means manual sync only. Default: 60.
|
// Zero means manual sync only. Default: 60.
|
||||||
SyncIntervalSeconds int `json:"sync_interval_seconds" env:"PICOCLAW_MEMORY_SYNC_INTERVAL_SECONDS"`
|
SyncIntervalSeconds int `json:"sync_interval_seconds" env:"DRAGONSCALE_MEMORY_SYNC_INTERVAL_SECONDS"`
|
||||||
|
|
||||||
// EncryptionKey enables encryption-at-rest on the local database file.
|
// EncryptionKey enables encryption-at-rest on the local database file.
|
||||||
// Empty string means no encryption.
|
// Empty string means no encryption.
|
||||||
EncryptionKey string `json:"encryption_key" env:"PICOCLAW_MEMORY_SYNC_ENCRYPTION_KEY"`
|
EncryptionKey string `json:"encryption_key" env:"DRAGONSCALE_MEMORY_SYNC_ENCRYPTION_KEY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentsConfig struct {
|
type AgentsConfig struct {
|
||||||
Defaults AgentDefaults `json:"defaults"`
|
Defaults AgentDefaults `json:"defaults"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContinuityRetentionConfig struct {
|
||||||
|
// MinMessages is the minimum number of recent messages always retained
|
||||||
|
// unsummarized for conversational continuity.
|
||||||
|
MinMessages int `json:"min_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_MIN_MESSAGES"`
|
||||||
|
|
||||||
|
// MaxMessages is the upper bound on retained recent messages, even when
|
||||||
|
// the token budget would allow more.
|
||||||
|
MaxMessages int `json:"max_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_MAX_MESSAGES"`
|
||||||
|
|
||||||
|
// TargetContextRatio is the target fraction of model context window reserved
|
||||||
|
// for retained recent messages.
|
||||||
|
TargetContextRatio float64 `json:"target_context_ratio" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_TARGET_CONTEXT_RATIO"`
|
||||||
|
|
||||||
|
// FailureKeepMessages is the fallback retained-message count used when
|
||||||
|
// summarization repeatedly fails.
|
||||||
|
FailureKeepMessages int `json:"failure_keep_messages" env:"DRAGONSCALE_AGENTS_DEFAULTS_CONTINUITY_RETENTION_FAILURE_KEEP_MESSAGES"`
|
||||||
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
// Sandbox is the directory for agent file operations (tools sandbox).
|
// Sandbox is the directory for agent file operations (tools sandbox).
|
||||||
// Defaults to $XDG_DATA_HOME/picoclaw/sandbox when empty.
|
// Defaults to $XDG_DATA_HOME/dragonscale/sandbox when empty.
|
||||||
Sandbox string `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"`
|
Sandbox string `json:"sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_SANDBOX"`
|
||||||
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
|
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"DRAGONSCALE_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
Model string `json:"model" env:"DRAGONSCALE_AGENTS_DEFAULTS_MODEL"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"DRAGONSCALE_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature float64 `json:"temperature" env:"DRAGONSCALE_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"DRAGONSCALE_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
|
ContinuityRetention ContinuityRetentionConfig `json:"continuity_retention"`
|
||||||
|
|
||||||
// Deprecated: Use Sandbox instead. Kept for backward compatibility during migration.
|
// Deprecated: Use Sandbox instead. Kept for backward compatibility during migration.
|
||||||
Workspace string `json:"workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"DRAGONSCALE_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
|
|
@ -155,88 +174,88 @@ type ChannelsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ENABLED"`
|
||||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
BridgeURL string `json:"bridge_url" env:"DRAGONSCALE_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"DRAGONSCALE_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"DRAGONSCALE_CHANNELS_TELEGRAM_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_FEISHU_ENABLED"`
|
||||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_ID"`
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_FEISHU_APP_SECRET"`
|
||||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
EncryptKey string `json:"encrypt_key" env:"DRAGONSCALE_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
VerificationToken string `json:"verification_token" env:"DRAGONSCALE_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DISCORD_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
Token string `json:"token" env:"DRAGONSCALE_CHANNELS_DISCORD_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MaixCamConfig struct {
|
type MaixCamConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ENABLED"`
|
||||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
Host string `json:"host" env:"DRAGONSCALE_CHANNELS_MAIXCAM_HOST"`
|
||||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
Port int `json:"port" env:"DRAGONSCALE_CHANNELS_MAIXCAM_PORT"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QQConfig struct {
|
type QQConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_QQ_ENABLED"`
|
||||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
AppID string `json:"app_id" env:"DRAGONSCALE_CHANNELS_QQ_APP_ID"`
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"DRAGONSCALE_CHANNELS_QQ_APP_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DingTalkConfig struct {
|
type DingTalkConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_DINGTALK_ENABLED"`
|
||||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
ClientID string `json:"client_id" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
ClientSecret string `json:"client_secret" env:"DRAGONSCALE_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlackConfig struct {
|
type SlackConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_SLACK_ENABLED"`
|
||||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
BotToken string `json:"bot_token" env:"DRAGONSCALE_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
AppToken string `json:"app_token" env:"DRAGONSCALE_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_LINE_ENABLED"`
|
||||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
ChannelSecret string `json:"channel_secret" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
ChannelAccessToken string `json:"channel_access_token" env:"DRAGONSCALE_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
WebhookHost string `json:"webhook_host" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
WebhookPort int `json:"webhook_port" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
WebhookPath string `json:"webhook_path" env:"DRAGONSCALE_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneBotConfig struct {
|
type OneBotConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_CHANNELS_ONEBOT_ENABLED"`
|
||||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
WSUrl string `json:"ws_url" env:"DRAGONSCALE_CHANNELS_ONEBOT_WS_URL"`
|
||||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
AccessToken string `json:"access_token" env:"DRAGONSCALE_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
ReconnectInterval int `json:"reconnect_interval" env:"DRAGONSCALE_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"DRAGONSCALE_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"DRAGONSCALE_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"DRAGONSCALE_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
}
|
}
|
||||||
|
|
||||||
type DevicesConfig struct {
|
type DevicesConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_DEVICES_ENABLED"`
|
||||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
MonitorUSB bool `json:"monitor_usb" env:"DRAGONSCALE_DEVICES_MONITOR_USB"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
|
|
@ -255,40 +274,70 @@ type ProvidersConfig struct {
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfiguredNames returns the names of providers that have credentials set
|
||||||
|
// (either an API key or an API base URL for local inference servers).
|
||||||
|
func (p ProvidersConfig) ConfiguredNames() []string {
|
||||||
|
entries := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{"anthropic", p.Anthropic.APIKey},
|
||||||
|
{"openai", p.OpenAI.APIKey},
|
||||||
|
{"openrouter", p.OpenRouter.APIKey},
|
||||||
|
{"gemini", p.Gemini.APIKey},
|
||||||
|
{"groq", p.Groq.APIKey},
|
||||||
|
{"zhipu", p.Zhipu.APIKey},
|
||||||
|
{"deepseek", p.DeepSeek.APIKey},
|
||||||
|
{"moonshot", p.Moonshot.APIKey},
|
||||||
|
{"nvidia", p.Nvidia.APIKey},
|
||||||
|
{"shengsuanyun", p.ShengSuanYun.APIKey},
|
||||||
|
{"ollama", p.Ollama.APIBase},
|
||||||
|
{"vllm", p.VLLM.APIBase},
|
||||||
|
{"github_copilot", p.GitHubCopilot.APIKey},
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.key != "" {
|
||||||
|
names = append(names, e.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
APIKey string `json:"api_key" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_KEY"`
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
APIBase string `json:"api_base" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
Proxy string `json:"proxy,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
AuthMethod string `json:"auth_method,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||||
Timeout int `json:"timeout,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
|
Timeout int `json:"timeout,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
|
||||||
ConnectMode string `json:"connect_mode,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
ConnectMode string `json:"connect_mode,omitzero" env:"DRAGONSCALE_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIProviderConfig struct {
|
type OpenAIProviderConfig struct {
|
||||||
ProviderConfig
|
ProviderConfig
|
||||||
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
WebSearch bool `json:"web_search" env:"DRAGONSCALE_PROVIDERS_OPENAI_WEB_SEARCH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
Host string `json:"host" env:"DRAGONSCALE_GATEWAY_HOST"`
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"DRAGONSCALE_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
Enabled bool `json:"enabled" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
APIKey string `json:"api_key" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"DRAGONSCALE_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
|
|
@ -298,7 +347,7 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"DRAGONSCALE_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -317,6 +366,12 @@ func DefaultConfig() *Config {
|
||||||
MaxTokens: 8192,
|
MaxTokens: 8192,
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxToolIterations: 20,
|
MaxToolIterations: 20,
|
||||||
|
ContinuityRetention: ContinuityRetentionConfig{
|
||||||
|
MinMessages: 4,
|
||||||
|
MaxMessages: 24,
|
||||||
|
TargetContextRatio: 0.10,
|
||||||
|
FailureKeepMessages: 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Channels: ChannelsConfig{
|
Channels: ChannelsConfig{
|
||||||
|
|
@ -493,6 +548,23 @@ func (c *Config) Validate() []string {
|
||||||
warnings = append(warnings, fmt.Sprintf("agents.defaults.max_tool_iterations=%d: should be > 0", c.Agents.Defaults.MaxToolIterations))
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.max_tool_iterations=%d: should be > 0", c.Agents.Defaults.MaxToolIterations))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continuity := c.Agents.Defaults.ContinuityRetention
|
||||||
|
if continuity.MinMessages <= 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.min_messages=%d: should be > 0", continuity.MinMessages))
|
||||||
|
}
|
||||||
|
if continuity.MaxMessages <= 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.max_messages=%d: should be > 0", continuity.MaxMessages))
|
||||||
|
}
|
||||||
|
if continuity.MaxMessages > 0 && continuity.MinMessages > continuity.MaxMessages {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.min_messages=%d exceeds max_messages=%d", continuity.MinMessages, continuity.MaxMessages))
|
||||||
|
}
|
||||||
|
if continuity.TargetContextRatio <= 0 || continuity.TargetContextRatio > 0.5 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.target_context_ratio=%.4f: expected (0, 0.5]", continuity.TargetContextRatio))
|
||||||
|
}
|
||||||
|
if continuity.FailureKeepMessages <= 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("agents.defaults.continuity_retention.failure_keep_messages=%d: should be > 0", continuity.FailureKeepMessages))
|
||||||
|
}
|
||||||
|
|
||||||
if c.Gateway.Port < 0 || c.Gateway.Port > 65535 {
|
if c.Gateway.Port < 0 || c.Gateway.Port > 65535 {
|
||||||
warnings = append(warnings, fmt.Sprintf("gateway.port=%d: must be in range 1-65535", c.Gateway.Port))
|
warnings = append(warnings, fmt.Sprintf("gateway.port=%d: must be in range 1-65535", c.Gateway.Port))
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +641,7 @@ func (c *Config) SandboxPath() string {
|
||||||
if dir, err := SandboxDir(); err == nil {
|
if dir, err := SandboxDir(); err == nil {
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
return expandHome("~/.local/share/picoclaw/sandbox")
|
return expandHome("~/.local/share/dragonscale/sandbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestrictToSandbox returns whether tool file operations should be restricted
|
// RestrictToSandbox returns whether tool file operations should be restricted
|
||||||
|
|
@ -602,7 +674,7 @@ func (c *Config) DBPath() string {
|
||||||
if p, err := DefaultDBPath(); err == nil {
|
if p, err := DefaultDBPath(); err == nil {
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
return expandHome("~/.local/share/picoclaw/picoclaw.db")
|
return expandHome("~/.local/share/dragonscale/dragonscale.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetAPIKey() string {
|
func (c *Config) GetAPIKey() string {
|
||||||
|
|
@ -669,12 +741,12 @@ func expandHome(path string) string {
|
||||||
|
|
||||||
// ─── XDG / platform path helpers ─────────────────────────────────────────────
|
// ─── XDG / platform path helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
const appName = "picoclaw"
|
const appName = "dragonscale"
|
||||||
|
|
||||||
// ConfigDir returns the platform-appropriate user configuration directory for
|
// ConfigDir returns the platform-appropriate user configuration directory for
|
||||||
// picoclaw, following XDG Base Directory spec on Linux
|
// dragonscale, following XDG Base Directory spec on Linux
|
||||||
// (~/.config/picoclaw), Library/Application Support on macOS, and
|
// (~/.config/dragonscale), Library/Application Support on macOS, and
|
||||||
// %AppData%\picoclaw on Windows. The directory is created if it does not exist.
|
// %AppData%\dragonscale on Windows. The directory is created if it does not exist.
|
||||||
func ConfigDir() (string, error) {
|
func ConfigDir() (string, error) {
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -687,10 +759,10 @@ func ConfigDir() (string, error) {
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataDir returns the platform-appropriate user data directory for picoclaw.
|
// DataDir returns the platform-appropriate user data directory for dragonscale.
|
||||||
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/picoclaw).
|
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/dragonscale).
|
||||||
// On macOS it uses ~/Library/Application Support/picoclaw; on Windows
|
// On macOS it uses ~/Library/Application Support/dragonscale; on Windows
|
||||||
// %LOCALAPPDATA%\picoclaw. The directory is created if it does not exist.
|
// %LOCALAPPDATA%\dragonscale. The directory is created if it does not exist.
|
||||||
func DataDir() (string, error) {
|
func DataDir() (string, error) {
|
||||||
var base string
|
var base string
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
|
|
@ -765,8 +837,8 @@ func SandboxDir() (string, error) {
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheDir returns the platform-appropriate user cache directory for picoclaw
|
// CacheDir returns the platform-appropriate user cache directory for dragonscale
|
||||||
// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if
|
// (XDG_CACHE_HOME on Linux → ~/.cache/dragonscale). The directory is created if
|
||||||
// it does not exist.
|
// it does not exist.
|
||||||
func CacheDir() (string, error) {
|
func CacheDir() (string, error) {
|
||||||
base, err := os.UserCacheDir()
|
base, err := os.UserCacheDir()
|
||||||
|
|
@ -782,7 +854,7 @@ func CacheDir() (string, error) {
|
||||||
|
|
||||||
// DefaultDBPath returns the canonical SQLite database path inside DataDir.
|
// DefaultDBPath returns the canonical SQLite database path inside DataDir.
|
||||||
// Callers that want to override this should check for a CLI flag or the
|
// Callers that want to override this should check for a CLI flag or the
|
||||||
// PICOCLAW_DB_PATH environment variable before falling back to this value.
|
// DRAGONSCALE_DB_PATH environment variable before falling back to this value.
|
||||||
func DefaultDBPath() (string, error) {
|
func DefaultDBPath() (string, error) {
|
||||||
dataDir, err := DataDir()
|
dataDir, err := DataDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -792,7 +864,7 @@ func DefaultDBPath() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfigPath returns the path to the primary JSON config file inside
|
// DefaultConfigPath returns the path to the primary JSON config file inside
|
||||||
// ConfigDir (picoclaw/config.json).
|
// ConfigDir (dragonscale/config.json).
|
||||||
func DefaultConfigPath() (string, error) {
|
func DefaultConfigPath() (string, error) {
|
||||||
cfgDir, err := ConfigDir()
|
cfgDir, err := ConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,23 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_ContinuityRetention(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.ContinuityRetention.MinMessages <= 0 {
|
||||||
|
t.Error("ContinuityRetention.MinMessages should be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.ContinuityRetention.MaxMessages < cfg.Agents.Defaults.ContinuityRetention.MinMessages {
|
||||||
|
t.Error("ContinuityRetention.MaxMessages should be >= MinMessages")
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio <= 0 {
|
||||||
|
t.Error("ContinuityRetention.TargetContextRatio should be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.ContinuityRetention.FailureKeepMessages < cfg.Agents.Defaults.ContinuityRetention.MinMessages {
|
||||||
|
t.Error("ContinuityRetention.FailureKeepMessages should be >= MinMessages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_Temperature verifies temperature has default value
|
// TestDefaultConfig_Temperature verifies temperature has default value
|
||||||
func TestDefaultConfig_Temperature(t *testing.T) {
|
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -300,6 +317,26 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidate_ContinuityRetentionConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.MinMessages = 8
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.MaxMessages = 4
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.TargetContextRatio = 0
|
||||||
|
cfg.Agents.Defaults.ContinuityRetention.FailureKeepMessages = 0
|
||||||
|
|
||||||
|
warnings := cfg.Validate()
|
||||||
|
joined := strings.Join(warnings, "\n")
|
||||||
|
if !strings.Contains(joined, "continuity_retention.min_messages") {
|
||||||
|
t.Fatalf("expected continuity retention min/max warning, got: %v", warnings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "continuity_retention.target_context_ratio") {
|
||||||
|
t.Fatalf("expected continuity retention ratio warning, got: %v", warnings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "continuity_retention.failure_keep_messages") {
|
||||||
|
t.Fatalf("expected continuity retention failure keep warning, got: %v", warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func containsMemoryWarning(s string) bool {
|
func containsMemoryWarning(s string) bool {
|
||||||
return strings.Contains(s, "memory.")
|
return strings.Contains(s, "memory.")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
pkg/tools/spawn_test.go
Normal file
44
pkg/tools/spawn_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,22 @@ package tools
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
fantasy "charm.land/fantasy"
|
fantasy "charm.land/fantasy"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubagentTask struct {
|
type SubagentTask struct {
|
||||||
ID string
|
ID string
|
||||||
|
ParentTaskID string
|
||||||
|
Depth int
|
||||||
Task string
|
Task string
|
||||||
Label string
|
Label string
|
||||||
|
DelegatedScope string
|
||||||
|
KeptWork string
|
||||||
OriginChannel string
|
OriginChannel string
|
||||||
OriginChatID string
|
OriginChatID string
|
||||||
Status string
|
Status string
|
||||||
|
|
@ -25,6 +30,50 @@ type SubagentTask struct {
|
||||||
// the import cycle between pkg/tools and pkg/fantasy.
|
// the import cycle between pkg/tools and pkg/fantasy.
|
||||||
type RunLoopFunc func(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error)
|
type RunLoopFunc func(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error)
|
||||||
|
|
||||||
|
type delegationCtxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
delegationTaskIDKey delegationCtxKey = "delegation_task_id"
|
||||||
|
delegationDepthKey delegationCtxKey = "delegation_depth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func delegationTaskIDFromContext(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(delegationTaskIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func delegationDepthFromContext(ctx context.Context) int {
|
||||||
|
if v, ok := ctx.Value(delegationDepthKey).(int); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func withDelegationContext(ctx context.Context, taskID string, depth int) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, delegationTaskIDKey, taskID)
|
||||||
|
ctx = context.WithValue(ctx, delegationDepthKey, depth)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelegationAuditEvent captures lineage and outcomes for delegated work.
|
||||||
|
type DelegationAuditEvent struct {
|
||||||
|
TaskID string
|
||||||
|
ParentTaskID string
|
||||||
|
Mode string
|
||||||
|
Depth int
|
||||||
|
Status string
|
||||||
|
Label string
|
||||||
|
DelegatedScope string
|
||||||
|
KeptWork string
|
||||||
|
Iterations int
|
||||||
|
ResultChars int
|
||||||
|
Error string
|
||||||
|
OriginChannel string
|
||||||
|
OriginChatID string
|
||||||
|
}
|
||||||
|
|
||||||
type SubagentManager struct {
|
type SubagentManager struct {
|
||||||
tasks map[string]*SubagentTask
|
tasks map[string]*SubagentTask
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -34,8 +83,12 @@ type SubagentManager struct {
|
||||||
workspace string
|
workspace string
|
||||||
tools *ToolRegistry
|
tools *ToolRegistry
|
||||||
maxIterations int
|
maxIterations int
|
||||||
|
maxDepth int
|
||||||
|
maxFanout int
|
||||||
|
activeChildren map[string]int
|
||||||
nextID int
|
nextID int
|
||||||
runLoop RunLoopFunc
|
runLoop RunLoopFunc
|
||||||
|
auditHook func(context.Context, DelegationAuditEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
|
func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
|
||||||
|
|
@ -47,25 +100,53 @@ func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace str
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
tools: NewToolRegistry(),
|
tools: NewToolRegistry(),
|
||||||
maxIterations: 10,
|
maxIterations: 10,
|
||||||
|
maxDepth: 3,
|
||||||
|
maxFanout: 4,
|
||||||
|
activeChildren: make(map[string]int),
|
||||||
nextID: 1,
|
nextID: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRunLoop injects the loop runner function. Must be called before any
|
// SetRunLoop injects the loop runner function. Must be called before any
|
||||||
// subagent execution. When nil, falls back to the local RunToolLoop.
|
// subagent execution.
|
||||||
func (sm *SubagentManager) SetRunLoop(fn RunLoopFunc) {
|
func (sm *SubagentManager) SetRunLoop(fn RunLoopFunc) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
sm.runLoop = fn
|
sm.runLoop = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDelegationLimits configures nested delegation guardrails.
|
||||||
|
func (sm *SubagentManager) SetDelegationLimits(maxDepth, maxFanout int) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
if maxDepth > 0 {
|
||||||
|
sm.maxDepth = maxDepth
|
||||||
|
}
|
||||||
|
if maxFanout > 0 {
|
||||||
|
sm.maxFanout = maxFanout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAuditHook registers a callback for delegation lineage/events.
|
||||||
|
func (sm *SubagentManager) SetAuditHook(hook func(context.Context, DelegationAuditEvent)) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.auditHook = hook
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SubagentManager) emitAudit(ctx context.Context, evt DelegationAuditEvent) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
hook := sm.auditHook
|
||||||
|
sm.mu.RUnlock()
|
||||||
|
if hook != nil {
|
||||||
|
hook(ctx, evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) getRunLoop() RunLoopFunc {
|
func (sm *SubagentManager) getRunLoop() RunLoopFunc {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
if sm.runLoop != nil {
|
|
||||||
return sm.runLoop
|
return sm.runLoop
|
||||||
}
|
|
||||||
return RunToolLoop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTools sets the tool registry for subagent execution.
|
// SetTools sets the tool registry for subagent execution.
|
||||||
|
|
@ -82,23 +163,66 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
|
||||||
sm.tools.Register(tool)
|
sm.tools.Register(tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string, callback AsyncCallback) (string, error) {
|
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, delegatedScope, keptWork, originChannel, originChatID string, callback AsyncCallback) (string, error) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
parentTaskID := delegationTaskIDFromContext(ctx)
|
||||||
|
if parentTaskID == "" {
|
||||||
|
parentTaskID = "root"
|
||||||
|
}
|
||||||
|
parentDepth := delegationDepthFromContext(ctx)
|
||||||
|
childDepth := parentDepth + 1
|
||||||
|
|
||||||
|
if childDepth > sm.maxDepth {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return "", fmt.Errorf("delegation depth exceeded: %d > %d", childDepth, sm.maxDepth)
|
||||||
|
}
|
||||||
|
if sm.activeChildren[parentTaskID] >= sm.maxFanout {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return "", fmt.Errorf("delegation fanout exceeded for %s: %d >= %d", parentTaskID, sm.activeChildren[parentTaskID], sm.maxFanout)
|
||||||
|
}
|
||||||
|
if parentDepth > 0 {
|
||||||
|
if strings.TrimSpace(delegatedScope) == "" || strings.TrimSpace(keptWork) == "" {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return "", fmt.Errorf("nested delegation requires delegated_scope and kept_work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sm.runLoop == nil {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return "", ErrRunLoopNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
taskID := fmt.Sprintf("subagent-%d", sm.nextID)
|
taskID := fmt.Sprintf("subagent-%d", sm.nextID)
|
||||||
sm.nextID++
|
sm.nextID++
|
||||||
|
sm.activeChildren[parentTaskID]++
|
||||||
|
|
||||||
subagentTask := &SubagentTask{
|
subagentTask := &SubagentTask{
|
||||||
ID: taskID,
|
ID: taskID,
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Depth: childDepth,
|
||||||
Task: task,
|
Task: task,
|
||||||
Label: label,
|
Label: label,
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
OriginChannel: originChannel,
|
OriginChannel: originChannel,
|
||||||
OriginChatID: originChatID,
|
OriginChatID: originChatID,
|
||||||
Status: "running",
|
Status: "running",
|
||||||
Created: time.Now().UnixMilli(),
|
Created: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
sm.tasks[taskID] = subagentTask
|
sm.tasks[taskID] = subagentTask
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
sm.emitAudit(ctx, DelegationAuditEvent{
|
||||||
|
TaskID: taskID,
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Mode: "spawn",
|
||||||
|
Depth: childDepth,
|
||||||
|
Status: "created",
|
||||||
|
Label: label,
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
|
OriginChannel: originChannel,
|
||||||
|
OriginChatID: originChatID,
|
||||||
|
})
|
||||||
|
|
||||||
// Start task in background with context cancellation support
|
// Start task in background with context cancellation support
|
||||||
go sm.runTask(ctx, subagentTask, callback)
|
go sm.runTask(ctx, subagentTask, callback)
|
||||||
|
|
@ -110,12 +234,10 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
||||||
task.Status = "running"
|
systemPrompt := `You are a subagent operating under the same runtime discipline as the main agent.
|
||||||
task.Created = time.Now().UnixMilli()
|
Use tools for actions. Do not claim actions without tool execution.
|
||||||
|
When discovering tools, call discovered tools directly; use tool_call only as fallback.
|
||||||
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
Complete the task independently and provide a clear summary of what was done.`
|
||||||
You have access to tools - use them as needed to complete your task.
|
|
||||||
After completing the task, provide a clear summary of what was done.`
|
|
||||||
|
|
||||||
// Check if context is already cancelled before starting
|
// Check if context is already cancelled before starting
|
||||||
select {
|
select {
|
||||||
|
|
@ -135,18 +257,51 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
runLoop := sm.getRunLoop()
|
runLoop := sm.getRunLoop()
|
||||||
loopResult, err := runLoop(ctx, ToolLoopConfig{
|
var loopResult *ToolLoopResult
|
||||||
|
var err error
|
||||||
|
if runLoop == nil {
|
||||||
|
err = ErrRunLoopNotConfigured
|
||||||
|
} else {
|
||||||
|
taskCtx := withDelegationContext(ctx, task.ID, task.Depth)
|
||||||
|
loopResult, err = runLoop(taskCtx, ToolLoopConfig{
|
||||||
Model: sm.model,
|
Model: sm.model,
|
||||||
ModelID: sm.defaultModel,
|
ModelID: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
Bus: sm.bus,
|
Bus: sm.bus,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
}, systemPrompt, task.Task, task.OriginChannel, task.OriginChatID)
|
}, systemPrompt, task.Task, task.OriginChannel, task.OriginChatID)
|
||||||
|
}
|
||||||
|
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
var result *ToolResult
|
var result *ToolResult
|
||||||
|
iterations := 0
|
||||||
|
resultChars := 0
|
||||||
|
errText := ""
|
||||||
|
finalStatus := task.Status
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if n := sm.activeChildren[task.ParentTaskID]; n <= 1 {
|
||||||
|
delete(sm.activeChildren, task.ParentTaskID)
|
||||||
|
} else {
|
||||||
|
sm.activeChildren[task.ParentTaskID] = n - 1
|
||||||
|
}
|
||||||
|
finalStatus = task.Status
|
||||||
|
resultChars = len(task.Result)
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
|
sm.emitAudit(ctx, DelegationAuditEvent{
|
||||||
|
TaskID: task.ID,
|
||||||
|
ParentTaskID: task.ParentTaskID,
|
||||||
|
Mode: "spawn",
|
||||||
|
Depth: task.Depth,
|
||||||
|
Status: finalStatus,
|
||||||
|
Label: task.Label,
|
||||||
|
DelegatedScope: task.DelegatedScope,
|
||||||
|
KeptWork: task.KeptWork,
|
||||||
|
Iterations: iterations,
|
||||||
|
ResultChars: resultChars,
|
||||||
|
Error: errText,
|
||||||
|
OriginChannel: task.OriginChannel,
|
||||||
|
OriginChatID: task.OriginChatID,
|
||||||
|
})
|
||||||
if callback != nil && result != nil {
|
if callback != nil && result != nil {
|
||||||
callback(ctx, result)
|
callback(ctx, result)
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +310,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Result = fmt.Sprintf("Error: %v", err)
|
task.Result = fmt.Sprintf("Error: %v", err)
|
||||||
|
errText = err.Error()
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
task.Status = "cancelled"
|
task.Status = "cancelled"
|
||||||
task.Result = "Task cancelled during execution"
|
task.Result = "Task cancelled during execution"
|
||||||
|
|
@ -170,6 +326,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
} else {
|
} else {
|
||||||
task.Status = "completed"
|
task.Status = "completed"
|
||||||
task.Result = loopResult.Content
|
task.Result = loopResult.Content
|
||||||
|
iterations = loopResult.Iterations
|
||||||
result = &ToolResult{
|
result = &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
|
ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content),
|
||||||
ForUser: loopResult.Content,
|
ForUser: loopResult.Content,
|
||||||
|
|
@ -195,7 +352,11 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
task, ok := sm.tasks[taskID]
|
task, ok := sm.tasks[taskID]
|
||||||
return task, ok
|
if !ok || task == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
copied := *task
|
||||||
|
return &copied, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
|
|
@ -204,7 +365,11 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
|
|
||||||
tasks := make([]*SubagentTask, 0, len(sm.tasks))
|
tasks := make([]*SubagentTask, 0, len(sm.tasks))
|
||||||
for _, task := range sm.tasks {
|
for _, task := range sm.tasks {
|
||||||
tasks = append(tasks, task)
|
if task == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := *task
|
||||||
|
tasks = append(tasks, &copied)
|
||||||
}
|
}
|
||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +409,14 @@ func (t *SubagentTool) Parameters() map[string]interface{} {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional short label for the task (for display)",
|
"description": "Optional short label for the task (for display)",
|
||||||
},
|
},
|
||||||
|
"delegated_scope": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "What part of the parent task is being delegated. Required for nested delegation.",
|
||||||
|
},
|
||||||
|
"kept_work": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "What work remains with the delegator. Required for nested delegation.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"task"},
|
"required": []string{"task"},
|
||||||
}
|
}
|
||||||
|
|
@ -261,21 +434,73 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
delegatedScope, _ := args["delegated_scope"].(string)
|
||||||
|
keptWork, _ := args["kept_work"].(string)
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPrompt := "You are a subagent. Complete the given task independently and provide a clear, concise result."
|
|
||||||
|
|
||||||
sm := t.manager
|
sm := t.manager
|
||||||
|
parentTaskID := delegationTaskIDFromContext(ctx)
|
||||||
|
if parentTaskID == "" {
|
||||||
|
parentTaskID = "root"
|
||||||
|
}
|
||||||
|
parentDepth := delegationDepthFromContext(ctx)
|
||||||
|
childDepth := parentDepth + 1
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
tools := sm.tools
|
||||||
maxIter := sm.maxIterations
|
maxIter := sm.maxIterations
|
||||||
|
maxDepth := sm.maxDepth
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
if childDepth > maxDepth {
|
||||||
|
return ErrorResult(fmt.Sprintf("delegation depth exceeded: %d > %d", childDepth, maxDepth))
|
||||||
|
}
|
||||||
|
if parentDepth > 0 {
|
||||||
|
if strings.TrimSpace(delegatedScope) == "" || strings.TrimSpace(keptWork) == "" {
|
||||||
|
return ErrorResult("nested delegation requires delegated_scope and kept_work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := "You are a subagent operating with main-loop control flow. Execute actions via tools, call discovered tools directly, and provide a clear concise result."
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
if sm.activeChildren[parentTaskID] >= sm.maxFanout {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return ErrorResult(fmt.Sprintf("delegation fanout exceeded for %s: %d >= %d", parentTaskID, sm.activeChildren[parentTaskID], sm.maxFanout))
|
||||||
|
}
|
||||||
|
sm.activeChildren[parentTaskID]++
|
||||||
|
sm.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
sm.mu.Lock()
|
||||||
|
if n := sm.activeChildren[parentTaskID]; n <= 1 {
|
||||||
|
delete(sm.activeChildren, parentTaskID)
|
||||||
|
} else {
|
||||||
|
sm.activeChildren[parentTaskID] = n - 1
|
||||||
|
}
|
||||||
|
sm.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
taskID := fmt.Sprintf("subagent-sync-%d", time.Now().UnixNano())
|
||||||
|
taskCtx := withDelegationContext(ctx, taskID, childDepth)
|
||||||
|
sm.emitAudit(ctx, DelegationAuditEvent{
|
||||||
|
TaskID: taskID,
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Mode: "sync",
|
||||||
|
Depth: childDepth,
|
||||||
|
Status: "created",
|
||||||
|
Label: label,
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
|
OriginChannel: t.originChannel,
|
||||||
|
OriginChatID: t.originChatID,
|
||||||
|
})
|
||||||
|
|
||||||
runLoop := sm.getRunLoop()
|
runLoop := sm.getRunLoop()
|
||||||
loopResult, err := runLoop(ctx, ToolLoopConfig{
|
if runLoop == nil {
|
||||||
|
return ErrorResult("Subagent runtime is not configured").WithError(ErrRunLoopNotConfigured)
|
||||||
|
}
|
||||||
|
loopResult, err := runLoop(taskCtx, ToolLoopConfig{
|
||||||
Model: sm.model,
|
Model: sm.model,
|
||||||
ModelID: sm.defaultModel,
|
ModelID: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
|
|
@ -284,6 +509,19 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
}, systemPrompt, task, t.originChannel, t.originChatID)
|
}, systemPrompt, task, t.originChannel, t.originChatID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
sm.emitAudit(ctx, DelegationAuditEvent{
|
||||||
|
TaskID: taskID,
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Mode: "sync",
|
||||||
|
Depth: childDepth,
|
||||||
|
Status: "failed",
|
||||||
|
Label: label,
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
|
Error: err.Error(),
|
||||||
|
OriginChannel: t.originChannel,
|
||||||
|
OriginChatID: t.originChatID,
|
||||||
|
})
|
||||||
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,6 +539,20 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
}
|
}
|
||||||
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s",
|
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s",
|
||||||
labelStr, loopResult.Iterations, loopResult.Content)
|
labelStr, loopResult.Iterations, loopResult.Content)
|
||||||
|
sm.emitAudit(ctx, DelegationAuditEvent{
|
||||||
|
TaskID: taskID,
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Mode: "sync",
|
||||||
|
Depth: childDepth,
|
||||||
|
Status: "completed",
|
||||||
|
Label: label,
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
|
Iterations: loopResult.Iterations,
|
||||||
|
ResultChars: len(loopResult.Content),
|
||||||
|
OriginChannel: t.originChannel,
|
||||||
|
OriginChatID: t.originChatID,
|
||||||
|
})
|
||||||
|
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: llmContent,
|
ForLLM: llmContent,
|
||||||
|
|
|
||||||
227
pkg/tools/subagent_manager_test.go
Normal file
227
pkg/tools/subagent_manager_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
fantasy "charm.land/fantasy"
|
fantasy "charm.land/fantasy"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockLanguageModel is a test implementation of fantasy.LanguageModel
|
// MockLanguageModel is a test implementation of fantasy.LanguageModel
|
||||||
|
|
@ -123,6 +123,24 @@ func TestSubagentTool_Parameters(t *testing.T) {
|
||||||
t.Errorf("Label type should be 'string', got: %v", label["type"])
|
t.Errorf("Label type should be 'string', got: %v", label["type"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify delegated_scope parameter
|
||||||
|
delegatedScope, ok := props["delegated_scope"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("delegated_scope parameter should exist")
|
||||||
|
}
|
||||||
|
if delegatedScope["type"] != "string" {
|
||||||
|
t.Errorf("delegated_scope type should be 'string', got: %v", delegatedScope["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify kept_work parameter
|
||||||
|
keptWork, ok := props["kept_work"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("kept_work parameter should exist")
|
||||||
|
}
|
||||||
|
if keptWork["type"] != "string" {
|
||||||
|
t.Errorf("kept_work type should be 'string', got: %v", keptWork["type"])
|
||||||
|
}
|
||||||
|
|
||||||
// Check required fields
|
// Check required fields
|
||||||
required, ok := params["required"].([]string)
|
required, ok := params["required"].([]string)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -316,6 +334,52 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
// but execution success indicates context was handled properly
|
// but execution success indicates context was handled properly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubagentTool_Execute_NestedDelegationRequiresScopeAndKeptWork(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
||||||
|
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
|
||||||
|
return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 1}, nil
|
||||||
|
})
|
||||||
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
|
// Simulate nested delegation (depth > 0) without delegated scope metadata.
|
||||||
|
ctx := withDelegationContext(context.Background(), "parent-task", 1)
|
||||||
|
result := tool.Execute(ctx, map[string]interface{}{
|
||||||
|
"task": "nested task",
|
||||||
|
"label": "nested",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("Expected nested delegation without delegated_scope/kept_work to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "nested delegation requires delegated_scope and kept_work") {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubagentTool_Execute_NestedDelegationWithMetadataSucceeds(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
||||||
|
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
|
||||||
|
return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 2}, nil
|
||||||
|
})
|
||||||
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
|
ctx := withDelegationContext(context.Background(), "parent-task", 1)
|
||||||
|
result := tool.Execute(ctx, map[string]interface{}{
|
||||||
|
"task": "nested task",
|
||||||
|
"label": "nested",
|
||||||
|
"delegated_scope": "collect additional facts",
|
||||||
|
"kept_work": "final synthesis",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected nested delegation with metadata to succeed, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSubagentTool_ForUserTruncation verifies long content is truncated for user
|
// TestSubagentTool_ForUserTruncation verifies long content is truncated for user
|
||||||
func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
||||||
provider := &MockLanguageModel{}
|
provider := &MockLanguageModel{}
|
||||||
|
|
|
||||||
20
pkg/tools/toolloop_test.go
Normal file
20
pkg/tools/toolloop_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue