picoclaw/pkg/agent/context.go
ZanzyTHEbar 99c9052308 refactor(agent): decompose AgentLoop into focused single-responsibility modules
The monolithic loop.go (previously ~2500 LOC with a FIXME to decompose it)
has been split into five focused files following the Ports-and-Adapters
principle. loop.go is now a thin orchestrator (~300 LOC) that wires the
components together.

New files:
- pkg/agent/agent_run.go: core run path — prepareRuntimeState, runAgentLoop,
  assembleContext, resolveFinalContent, and the Generate/Stream dispatch.
  Owns conversationIDs writes and activeSessionKey writes.
- pkg/agent/command_handler.go: slash-command registry (SlashCommand type),
  built-in /help, /models, /clear, /memory, /debug handlers, and the
  dispatchCommand entry point.
- pkg/agent/helpers.go: stateless helper functions shared across the package —
  initSecretStore, loadIdentityDocs, seedSystemPrompt, buildDenyPatterns,
  resolveModelForChannel, and similar utilities.
- pkg/agent/message_router.go: inbound message routing — RecordLastChannel,
  RecordLastChatID, RouteMessage, and the channel-specific dispatch logic
  that was previously embedded in the main loop.
- pkg/agent/summarizer.go: async session summarization — forceCompression,
  summarizeSession, and the summarizing/summarizeFailures sync.Map owners.

Updated files:
- pkg/agent/loop.go: stripped to struct definition, NewAgentLoop constructor,
  Run/Stop lifecycle, and field ownership comments; removed the FIXME banner.
- pkg/agent/context.go: minor import cleanup.
- pkg/agent/toolloop.go: minor import cleanup.
- pkg/agent/loop_test.go: updated test helpers for new file layout.
2026-02-21 20:39:30 +00:00

408 lines
13 KiB
Go

package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/ZanzyTHEbar/dragonscale/pkg"
"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 {
workspace string
skillsLoader *skills.SkillsLoader
memoryStore memory.Memory // 3-tier MemGPT memory (may be nil)
delegate memory.MemoryDelegate // Direct delegate for document loading (may be nil)
tools *tools.ToolRegistry // Direct reference to tool registry
observationBlock string // Pre-rendered observation block for prompt injection
knowledgeBlock string // Pre-rendered knowledge block from Focus completions
dagBlock string // Pre-rendered DAG compressed history
contextWindow int // Max tokens for context window (0 = no limit)
}
func NewContextBuilder(workspace string) *ContextBuilder {
// Primary skills dir: XDG data dir (installed skills).
// Falls back to workspace/skills for legacy setups.
primarySkillsDir := filepath.Join(workspace, "skills")
if dir, err := config.SkillsDir(); err == nil {
primarySkillsDir = dir
}
// Global skills: ~/.config/dragonscale/skills (user-level overrides).
globalSkillsDir := ""
if dir, err := config.ConfigDir(); err == nil {
globalSkillsDir = filepath.Join(dir, "skills")
}
// Builtin skills: skills/ directory relative to the binary's working dir.
wd, _ := os.Getwd()
builtinSkillsDir := filepath.Join(wd, "skills")
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(primarySkillsDir, globalSkillsDir, builtinSkillsDir),
}
}
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
cb.tools = registry
}
// SetMemoryStore sets the 3-tier MemGPT memory store for working context injection.
func (cb *ContextBuilder) SetMemoryStore(ms memory.Memory) {
cb.memoryStore = ms
}
// SetDelegate sets the memory delegate for document loading.
func (cb *ContextBuilder) SetDelegate(del memory.MemoryDelegate) {
cb.delegate = del
}
func (cb *ContextBuilder) SkillsLoader() *skills.SkillsLoader {
return cb.skillsLoader
}
func (cb *ContextBuilder) SetObservationBlock(block string) {
cb.observationBlock = block
}
// SetKnowledgeBlock sets the pre-rendered knowledge block from completed Focus sessions.
func (cb *ContextBuilder) SetKnowledgeBlock(block string) {
cb.knowledgeBlock = block
}
// SetDAGBlock sets the pre-rendered DAG compressed history for prompt injection.
func (cb *ContextBuilder) SetDAGBlock(block string) {
cb.dagBlock = block
}
// SetContextWindow configures the token budget for the system prompt.
func (cb *ContextBuilder) SetContextWindow(tokens int) {
cb.contextWindow = tokens
}
func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
// Build tools section dynamically
toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# dragonscale 🦞
You are dragonscale, a helpful AI assistant.
## Current Time
%s
## Runtime
%s
## Workspace
Your workspace is at: %s
- Skills: %s/skills/{skill-name}/SKILL.md
%s
## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - Use the memory tool to store important facts, preferences, and decisions.
4. **No fabricated data** - If access is denied, a tool fails, or a request is outside workspace/sandbox, explicitly say so. Do NOT invent file contents, command output, credentials, or sample sensitive data.
5. **Completion discipline** - For actionable requests, execute the required tools before your final answer. Do NOT end with only intent statements like "I'll do that" or "let me do that."
6. **Context Management** - You MUST consolidate your context to stay effective during long tasks. Use start_focus at the beginning of any investigation or multi-step task. After 10-15 tool calls, call complete_focus with a summary of what you learned and accomplished. This compresses your working context and persists knowledge for future reference. Failing to consolidate will degrade your performance as context grows.`,
now, runtime, workspacePath, workspacePath, toolsSection)
}
func (cb *ContextBuilder) buildToolsSection() string {
if cb.tools == nil {
return ""
}
summaries := cb.tools.GetSummaries()
if len(summaries) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
sb.WriteString("\n")
}
return sb.String()
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
type section struct {
name string
content string
priority int // lower = higher priority (kept first when trimming)
}
// Collect sections in priority order
sections := []section{}
// P0: Core identity (always included)
sections = append(sections, section{"identity", cb.getIdentity(), 0})
// P1: Bootstrap files (user identity)
if bc := cb.LoadBootstrapFiles(); bc != "" {
sections = append(sections, section{"bootstrap", bc, 1})
}
// P2: Skills index (lightweight Level 1 metadata)
if summary := cb.skillsLoader.BuildSkillsSummary(); summary != "" {
sections = append(sections, section{"skills", fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill:
1. Use **skill_search** to find relevant skills by keyword
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.
%s`, summary), 2})
}
// P3: Working context (hot tier — highly dynamic, high value)
if cb.memoryStore != nil {
if wc := cb.buildWorkingContextSection(); wc != "" {
sections = append(sections, section{"working_context", wc, 3})
}
}
// P4: Observation block
if cb.observationBlock != "" {
sections = append(sections, section{"observations", "# Observations\n\n" + cb.observationBlock, 4})
}
// P5: Knowledge block
if cb.knowledgeBlock != "" {
sections = append(sections, section{"knowledge", cb.knowledgeBlock, 5})
}
// P6: DAG compressed history (lowest priority — can be reconstructed)
if cb.dagBlock != "" {
sections = append(sections, section{"dag", "# Conversation History (Compressed)\n\n" + cb.dagBlock, 6})
}
// Token budget enforcement: if we exceed ~40% of context window for the
// system prompt, trim lowest-priority sections first.
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 budgetTokens > 0 && totalTokens > budgetTokens {
logger.WarnCF("context", "System prompt exceeds token budget, trimming low-priority sections",
map[string]interface{}{
"total_tokens": totalTokens,
"budget_tokens": budgetTokens,
"sections": len(sections),
})
// Trim from lowest priority (highest number) first
for i := len(sections) - 1; i >= 0 && totalTokens > budgetTokens; i-- {
if sections[i].priority >= 5 { // only trim P5+ (knowledge, dag)
totalTokens -= sectionTokens[i]
sections[i].content = ""
}
}
}
parts := make([]string, 0, len(sections))
for _, s := range sections {
if s.content != "" {
parts = append(parts, s.content)
}
}
prompt := strings.Join(parts, "\n\n---\n\n")
// Log token estimate for observability
tokenEst := observation.EstimateTokens(prompt)
logger.DebugCF("context", "System prompt token estimate",
map[string]interface{}{
"chars": len(prompt),
"tokens_est": tokenEst,
"sections": len(parts),
})
return 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) tokenBudgetTokens() int {
if cb.contextWindow <= 0 {
return 0
}
// Reserve ~40% of context window for system prompt
return int(float64(cb.contextWindow) * 0.4)
}
func (cb *ContextBuilder) LoadBootstrapFiles() string {
if cb.delegate == nil {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
docs, err := cb.delegate.ListDocumentsByCategory(ctx, pkg.NAME, "bootstrap")
if err != nil || len(docs) == 0 {
return ""
}
var result string
for _, doc := range docs {
result += fmt.Sprintf("## %s\n\n%s\n\n", doc.Name, doc.Content)
}
return result
}
// buildWorkingContextSection returns the working context section for the system prompt.
// It includes the hot-tier working context buffer and memory usage instructions.
func (cb *ContextBuilder) buildWorkingContextSection() string {
if cb.memoryStore == nil {
return ""
}
// Use a background context for system prompt building (non-blocking)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var parts []string
// Inject working context (hot tier)
wc, err := cb.memoryStore.GetWorkingContext(ctx, pkg.NAME, "default")
if err == nil && wc != "" {
parts = append(parts, "## Working Context\n\n"+wc)
}
// Memory system instructions
parts = append(parts, `## Memory System
You have a 3-tier memory system accessible via the **memory** tool:
- **Working Context** (hot): Always loaded. Use memory tool to update it with durable facts.
- **Recall** (warm): Scored memory items. Search returns the most relevant.
- **Archival** (cold): Large documents chunked and embedded for retrieval.
Use the memory tool to:
- **search**: Find relevant memories across all tiers.
- **write**: Store important facts, preferences, decisions.
- **status**: Check memory pressure and usage.
Store important user preferences, key decisions, and facts you want to remember long-term.`)
return strings.Join(parts, "\n\n")
}
func (cb *ContextBuilder) BuildMessages(history []messages.Message, summary string, currentMessage string, media []string, channel, chatID string) []messages.Message {
msgs := []messages.Message{}
systemPrompt := cb.BuildSystemPrompt()
// Add Current Session info if provided
if channel != "" && chatID != "" {
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
}
// Log system prompt summary for debugging (debug mode only)
logger.DebugCF("agent", "System prompt built",
map[string]interface{}{
"total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
})
// Log preview of system prompt (avoid logging huge content)
preview := systemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
map[string]interface{}{
"preview": preview,
})
if summary != "" {
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
}
// Note: Orphaned tool messages are now prevented at the source by
// tool-call-aware truncation in SessionManager.TruncateHistory().
msgs = append(msgs, messages.Message{
Role: "system",
Content: systemPrompt,
})
msgs = append(msgs, history...)
msgs = append(msgs, messages.Message{
Role: "user",
Content: currentMessage,
})
return msgs
}
func (cb *ContextBuilder) AddToolResult(msgs []messages.Message, toolCallID, toolName, result string) []messages.Message {
msgs = append(msgs, messages.Message{
Role: "tool",
Content: result,
ToolCallID: toolCallID,
})
return msgs
}
func (cb *ContextBuilder) AddAssistantMessage(msgs []messages.Message, content string, toolCalls []map[string]interface{}) []messages.Message {
msg := messages.Message{
Role: "assistant",
Content: content,
}
// Always add assistant message, whether or not it has tool calls
msgs = append(msgs, msg)
return msgs
}
// GetSkillsInfo returns information about available skills (metadata only).
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
return map[string]interface{}{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
}
}