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