mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1332 lines
40 KiB
Go
1332 lines
40 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
type ContextBuilder struct {
|
|
workspace string
|
|
skillsLoader *skills.SkillsLoader
|
|
memory *MemoryStore
|
|
splitOnMarker bool
|
|
agentDiscovery func(agentID string) []AgentDescriptor
|
|
promptRegistry *PromptRegistry
|
|
|
|
// Cache for system prompt to avoid rebuilding on every call.
|
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
|
// The cache auto-invalidates when workspace source files change (mtime check).
|
|
systemPromptMutex sync.RWMutex
|
|
cachedSystemPrompt string
|
|
cachedAt time.Time // max observed mtime across tracked paths at cache build time
|
|
|
|
// existedAtCache tracks which source file paths existed the last time the
|
|
// cache was built. This lets sourceFilesChanged detect files that are newly
|
|
// created (didn't exist at cache time, now exist) or deleted (existed at
|
|
// cache time, now gone) — both of which should trigger a cache rebuild.
|
|
existedAtCache map[string]bool
|
|
|
|
// skillFilesAtCache snapshots the skill tree file set and mtimes at cache
|
|
// build time. This catches nested file creations/deletions/mtime changes
|
|
// that may not update the top-level skill root directory mtime.
|
|
skillFilesAtCache map[string]time.Time
|
|
}
|
|
|
|
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
|
if useBM25 || useRegex {
|
|
if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{
|
|
useBM25: useBM25,
|
|
useRegex: useRegex,
|
|
}); err != nil {
|
|
logger.WarnCF(
|
|
"agent",
|
|
"Failed to register tool discovery prompt contributor",
|
|
map[string]any{
|
|
"error": err.Error(),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
return cb
|
|
}
|
|
|
|
func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
|
|
cb.splitOnMarker = enabled
|
|
return cb
|
|
}
|
|
|
|
func (cb *ContextBuilder) WithAgentDiscovery(
|
|
agentID string,
|
|
discover func(agentID string) []AgentDescriptor,
|
|
) *ContextBuilder {
|
|
cb.agentDiscovery = discover
|
|
if discover != nil {
|
|
if err := cb.RegisterPromptContributor(agentDiscoveryPromptContributor{
|
|
agentID: agentID,
|
|
discover: discover,
|
|
}); err != nil {
|
|
logger.WarnCF(
|
|
"agent",
|
|
"Failed to register agent discovery prompt contributor",
|
|
map[string]any{
|
|
"error": err.Error(),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
return cb
|
|
}
|
|
|
|
func getGlobalConfigDir() string {
|
|
return config.GetHome()
|
|
}
|
|
|
|
func NewContextBuilder(workspace string) *ContextBuilder {
|
|
// builtin skills: skills directory in current project
|
|
// Use the skills/ directory under the current working directory
|
|
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
|
if builtinSkillsDir == "" {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
// os.Getwd failure is extremely rare; fall back to empty
|
|
// string so that filepath.Join produces a relative "skills"
|
|
// path, preserving the original lookup behavior.
|
|
wd = ""
|
|
}
|
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
|
}
|
|
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
|
|
|
return &ContextBuilder{
|
|
workspace: workspace,
|
|
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
|
memory: NewMemoryStore(workspace),
|
|
promptRegistry: NewPromptRegistry(),
|
|
}
|
|
}
|
|
|
|
func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error {
|
|
err := cb.promptRegistryOrDefault().RegisterSource(desc)
|
|
if err == nil {
|
|
cb.InvalidateCache()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error {
|
|
err := cb.promptRegistryOrDefault().RegisterContributor(contributor)
|
|
if err == nil {
|
|
cb.InvalidateCache()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
|
|
if cb.promptRegistry == nil {
|
|
cb.promptRegistry = NewPromptRegistry()
|
|
}
|
|
return cb.promptRegistry
|
|
}
|
|
|
|
func (cb *ContextBuilder) getIdentity(includeToolUseRule bool) string {
|
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
|
version := config.FormatVersion()
|
|
rules := []string{}
|
|
if includeToolUseRule {
|
|
rules = append(rules, toolUseSystemPromptRule())
|
|
}
|
|
accuracyRule := "**Be helpful and accurate** - Briefly explain what you're doing."
|
|
if includeToolUseRule {
|
|
accuracyRule = "**Be helpful and accurate** - When using tools, briefly explain what you're doing."
|
|
}
|
|
rules = append(
|
|
rules,
|
|
accuracyRule,
|
|
"**Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.",
|
|
)
|
|
if includeToolUseRule {
|
|
rules = append(
|
|
rules,
|
|
fmt.Sprintf(
|
|
"**Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md",
|
|
workspacePath,
|
|
),
|
|
)
|
|
}
|
|
for i, rule := range rules {
|
|
rules[i] = fmt.Sprintf("%d. %s", i+1, rule)
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
`# picoclaw 🦞 (%s)
|
|
|
|
You are picoclaw, a helpful AI assistant.
|
|
|
|
## Workspace
|
|
Your workspace is at: %s
|
|
- Memory: %s/memory/MEMORY.md
|
|
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
|
- Skills: %s/skills/{skill-name}/SKILL.md
|
|
|
|
## Important Rules
|
|
|
|
%s
|
|
`,
|
|
version,
|
|
workspacePath,
|
|
workspacePath,
|
|
workspacePath,
|
|
workspacePath,
|
|
strings.Join(rules, "\n\n"),
|
|
)
|
|
}
|
|
|
|
func formatToolDiscoveryRule(useBM25, useRegex bool) string {
|
|
if !useBM25 && !useRegex {
|
|
return ""
|
|
}
|
|
|
|
var toolNames []string
|
|
if useBM25 {
|
|
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
|
}
|
|
if useRegex {
|
|
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
|
|
strings.Join(toolNames, " or "),
|
|
)
|
|
}
|
|
|
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
|
return renderPromptPartsLegacy(cb.BuildSystemPromptParts())
|
|
}
|
|
|
|
func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
|
return cb.buildSystemPromptParts(systemPromptBuildOptions{
|
|
IncludeSkillCatalog: true,
|
|
IncludeToolUseRule: true,
|
|
})
|
|
}
|
|
|
|
type systemPromptBuildOptions struct {
|
|
IncludeSkillCatalog bool
|
|
IncludeToolUseRule bool
|
|
AllowedSkills []string
|
|
AllowedTools []string
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildSystemPromptParts(opts systemPromptBuildOptions) []PromptPart {
|
|
stack := NewPromptStack(cb.promptRegistryOrDefault())
|
|
add := func(part PromptPart) {
|
|
if err := stack.Add(part); err != nil {
|
|
logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{
|
|
"id": part.ID,
|
|
"layer": part.Layer,
|
|
"slot": part.Slot,
|
|
"source": part.Source.ID,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Core identity section
|
|
add(PromptPart{
|
|
ID: "kernel.identity",
|
|
Layer: PromptLayerKernel,
|
|
Slot: PromptSlotIdentity,
|
|
Source: PromptSource{ID: PromptSourceKernel, Name: "identity"},
|
|
Title: "picoclaw identity",
|
|
Content: cb.getIdentity(opts.IncludeToolUseRule),
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
})
|
|
|
|
// Bootstrap files
|
|
bootstrapContent := cb.LoadBootstrapFiles()
|
|
if bootstrapContent != "" {
|
|
add(PromptPart{
|
|
ID: "instruction.workspace",
|
|
Layer: PromptLayerInstruction,
|
|
Slot: PromptSlotWorkspace,
|
|
Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"},
|
|
Title: "workspace instructions",
|
|
Content: bootstrapContent,
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
})
|
|
}
|
|
|
|
// Skills - show summary, AI can read full content with read_file tool
|
|
skillsSummary := ""
|
|
if opts.IncludeSkillCatalog {
|
|
skillsSummary = cb.buildSkillsSummary(opts.AllowedSkills)
|
|
}
|
|
if skillsSummary != "" {
|
|
skillIntro := "The following skills extend your capabilities."
|
|
readFileAllowed := promptAllowsTool(
|
|
PromptBuildRequest{AllowedTools: opts.AllowedTools},
|
|
"read_file",
|
|
)
|
|
if opts.IncludeToolUseRule && readFileAllowed {
|
|
skillIntro += " To use a skill, read its SKILL.md file using the read_file tool."
|
|
}
|
|
add(PromptPart{
|
|
ID: "capability.skill_catalog",
|
|
Layer: PromptLayerCapability,
|
|
Slot: PromptSlotSkillCatalog,
|
|
Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"},
|
|
Title: "skill catalog",
|
|
Content: fmt.Sprintf(`# Skills
|
|
|
|
%s
|
|
|
|
%s`, skillIntro, skillsSummary),
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
})
|
|
}
|
|
|
|
// Memory context
|
|
memoryContext := cb.memory.GetMemoryContext()
|
|
if memoryContext != "" {
|
|
add(PromptPart{
|
|
ID: "context.memory",
|
|
Layer: PromptLayerContext,
|
|
Slot: PromptSlotMemory,
|
|
Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"},
|
|
Title: "memory",
|
|
Content: "# Memory\n\n" + memoryContext,
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
})
|
|
}
|
|
|
|
// Multi-Message Sending (if enabled)
|
|
if cb.splitOnMarker {
|
|
add(PromptPart{
|
|
ID: "context.output_policy.split_on_marker",
|
|
Layer: PromptLayerContext,
|
|
Slot: PromptSlotOutput,
|
|
Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"},
|
|
Title: "multi-message output policy",
|
|
Content: `# MULTI-MESSAGE OUTPUT
|
|
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
|
|
|
|
Each part separated by the marker will be sent as an independent message.`,
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
})
|
|
}
|
|
|
|
stack.Seal()
|
|
return stack.Parts()
|
|
}
|
|
|
|
// BuildSystemPromptWithCache returns the cached system prompt if available
|
|
// and source files haven't changed, otherwise builds and caches it.
|
|
// Source file changes are detected via mtime checks (cheap stat calls).
|
|
func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
|
// Try read lock first — fast path when cache is valid
|
|
cb.systemPromptMutex.RLock()
|
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
|
result := cb.cachedSystemPrompt
|
|
cb.systemPromptMutex.RUnlock()
|
|
return result
|
|
}
|
|
cb.systemPromptMutex.RUnlock()
|
|
|
|
// Acquire write lock for building
|
|
cb.systemPromptMutex.Lock()
|
|
defer cb.systemPromptMutex.Unlock()
|
|
|
|
// Double-check: another goroutine may have rebuilt while we waited
|
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
|
return cb.cachedSystemPrompt
|
|
}
|
|
|
|
// Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
|
|
// This way cachedAt reflects the pre-build state: if a file is modified
|
|
// during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
|
|
// so the next sourceFilesChangedLocked check will correctly trigger a
|
|
// rebuild. The alternative (baseline after build) risks caching stale
|
|
// content with a too-new baseline, making the staleness invisible.
|
|
baseline := cb.buildCacheBaseline()
|
|
prompt := cb.BuildSystemPrompt()
|
|
cb.cachedSystemPrompt = prompt
|
|
cb.cachedAt = baseline.maxMtime
|
|
cb.existedAtCache = baseline.existed
|
|
cb.skillFilesAtCache = baseline.skillFiles
|
|
|
|
logger.DebugCF("agent", "System prompt cached",
|
|
map[string]any{
|
|
"length": len(prompt),
|
|
})
|
|
|
|
return prompt
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildSystemPromptForRequest(
|
|
req PromptBuildRequest,
|
|
) (string, []providers.ContentBlock) {
|
|
if req.SuppressDefaultSystemPrompt {
|
|
return "", nil
|
|
}
|
|
|
|
useDefaultCache := !req.SuppressSkillContext &&
|
|
!req.SuppressToolUseRule &&
|
|
len(req.AllowedSkills) == 0 &&
|
|
len(req.AllowedTools) == 0
|
|
if useDefaultCache {
|
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
|
return staticPrompt, []providers.ContentBlock{
|
|
promptContentBlock(PromptPart{
|
|
ID: "kernel.static",
|
|
Layer: PromptLayerKernel,
|
|
Slot: PromptSlotIdentity,
|
|
Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
|
|
Content: staticPrompt,
|
|
}, &providers.CacheControl{Type: "ephemeral"}),
|
|
}
|
|
}
|
|
|
|
parts := cb.buildSystemPromptParts(systemPromptBuildOptions{
|
|
IncludeSkillCatalog: !req.SuppressSkillContext,
|
|
IncludeToolUseRule: !req.SuppressToolUseRule,
|
|
AllowedSkills: req.AllowedSkills,
|
|
AllowedTools: req.AllowedTools,
|
|
})
|
|
staticPrompt := renderPromptPartsLegacy(parts)
|
|
blocks := make([]providers.ContentBlock, 0, len(parts))
|
|
for _, part := range parts {
|
|
if strings.TrimSpace(part.Content) == "" {
|
|
continue
|
|
}
|
|
blocks = append(blocks, promptContentBlock(part, cacheControlForPromptPart(part)))
|
|
}
|
|
return staticPrompt, blocks
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildSkillsSummary(allowed []string) string {
|
|
if cb.skillsLoader == nil {
|
|
return ""
|
|
}
|
|
if len(allowed) == 0 {
|
|
return cb.skillsLoader.BuildSkillsSummary()
|
|
}
|
|
allowedSet := cleanAllowedSet(allowed)
|
|
if len(allowedSet) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, "<skills>")
|
|
for _, s := range cb.skillsLoader.ListSkills() {
|
|
if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(s.Name))]; !ok {
|
|
continue
|
|
}
|
|
lines = append(lines, " <skill>")
|
|
lines = append(lines, fmt.Sprintf(" <name>%s</name>", xmlEscapeForPrompt(s.Name)))
|
|
lines = append(
|
|
lines,
|
|
fmt.Sprintf(" <description>%s</description>", xmlEscapeForPrompt(s.Description)),
|
|
)
|
|
lines = append(
|
|
lines,
|
|
fmt.Sprintf(" <location>%s</location>", xmlEscapeForPrompt(s.Path)),
|
|
)
|
|
lines = append(lines, fmt.Sprintf(" <source>%s</source>", xmlEscapeForPrompt(s.Source)))
|
|
lines = append(lines, " </skill>")
|
|
}
|
|
if len(lines) == 1 {
|
|
return ""
|
|
}
|
|
lines = append(lines, "</skills>")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func xmlEscapeForPrompt(s string) string {
|
|
replacer := strings.NewReplacer(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
"\"", """,
|
|
"'", "'",
|
|
)
|
|
return replacer.Replace(s)
|
|
}
|
|
|
|
// EstimateSystemTokens estimates the token count of the full system message
|
|
// that would be sent to the LLM, mirroring the composition logic in BuildMessages.
|
|
// It includes: static prompt, dynamic context, active skills, and summary with
|
|
// wrapping prefixes and separators. This avoids needing all per-request parameters
|
|
// that BuildMessages requires (media, channel, chatID, sender, etc.).
|
|
func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int {
|
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
|
|
|
// Dynamic context is small and varies per request; use a representative estimate.
|
|
// Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info.
|
|
const dynamicContextChars = 300
|
|
|
|
totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars
|
|
|
|
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
|
|
totalChars += utf8.RuneCountInString(skillsText)
|
|
totalChars += 7 // separator \n\n---\n\n
|
|
}
|
|
|
|
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{
|
|
Summary: summary,
|
|
ActiveSkills: append([]string(nil), activeSkills...),
|
|
}); err == nil {
|
|
for _, part := range contributedParts {
|
|
if strings.TrimSpace(part.Content) == "" {
|
|
continue
|
|
}
|
|
totalChars += utf8.RuneCountInString(part.Content)
|
|
totalChars += 7 // separator
|
|
}
|
|
}
|
|
|
|
if summary != "" {
|
|
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
|
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
|
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n"
|
|
totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary)
|
|
totalChars += 7 // separator
|
|
}
|
|
|
|
return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens
|
|
}
|
|
|
|
// InvalidateCache clears the cached system prompt.
|
|
// Normally not needed because the cache auto-invalidates via mtime checks,
|
|
// but this is useful for tests or explicit reload commands.
|
|
func (cb *ContextBuilder) InvalidateCache() {
|
|
cb.systemPromptMutex.Lock()
|
|
defer cb.systemPromptMutex.Unlock()
|
|
|
|
cb.cachedSystemPrompt = ""
|
|
cb.cachedAt = time.Time{}
|
|
cb.existedAtCache = nil
|
|
cb.skillFilesAtCache = nil
|
|
|
|
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
|
}
|
|
|
|
// sourcePaths returns non-skill workspace source files tracked for cache
|
|
// invalidation (bootstrap files + memory). Skill roots are handled separately
|
|
// because they require both directory-level and recursive file-level checks.
|
|
func (cb *ContextBuilder) sourcePaths() []string {
|
|
agentDefinition := cb.LoadAgentDefinition()
|
|
paths := agentDefinition.trackedPaths(cb.workspace)
|
|
paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md"))
|
|
return uniquePaths(paths)
|
|
}
|
|
|
|
// skillRoots returns all skill root directories that can affect
|
|
// BuildSkillsSummary output (workspace/global/builtin).
|
|
func (cb *ContextBuilder) skillRoots() []string {
|
|
if cb.skillsLoader == nil {
|
|
return []string{filepath.Join(cb.workspace, "skills")}
|
|
}
|
|
|
|
roots := cb.skillsLoader.SkillRoots()
|
|
if len(roots) == 0 {
|
|
return []string{filepath.Join(cb.workspace, "skills")}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// cacheBaseline holds the file existence snapshot and the latest observed
|
|
// mtime across all tracked paths. Used as the cache reference point.
|
|
type cacheBaseline struct {
|
|
existed map[string]bool
|
|
skillFiles map[string]time.Time
|
|
maxMtime time.Time
|
|
}
|
|
|
|
// buildCacheBaseline records which tracked paths currently exist and computes
|
|
// the latest mtime across all tracked files + skills directory contents.
|
|
// Called under write lock when the cache is built.
|
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
|
skillRoots := cb.skillRoots()
|
|
|
|
// All paths whose existence we track: source files + all skill roots.
|
|
allPaths := append(cb.sourcePaths(), skillRoots...)
|
|
|
|
existed := make(map[string]bool, len(allPaths))
|
|
skillFiles := make(map[string]time.Time)
|
|
var maxMtime time.Time
|
|
|
|
for _, p := range allPaths {
|
|
info, err := os.Stat(p)
|
|
existed[p] = err == nil
|
|
if err == nil && info.ModTime().After(maxMtime) {
|
|
maxMtime = info.ModTime()
|
|
}
|
|
}
|
|
|
|
// Walk all skill roots recursively to snapshot skill files and mtimes.
|
|
// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
|
|
for _, root := range skillRoots {
|
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr == nil && !d.IsDir() {
|
|
if info, err := os.Stat(path); err == nil {
|
|
skillFiles[path] = info.ModTime()
|
|
if info.ModTime().After(maxMtime) {
|
|
maxMtime = info.ModTime()
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
|
// Use a very old non-zero time so that:
|
|
// 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
|
|
// 2. Any real file created afterwards has mtime > cachedAt, so it
|
|
// will be detected by fileChangedSince (unlike time.Now() which
|
|
// could race with a file whose mtime <= Now).
|
|
if maxMtime.IsZero() {
|
|
maxMtime = time.Unix(1, 0)
|
|
}
|
|
|
|
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}
|
|
}
|
|
|
|
// sourceFilesChangedLocked checks whether any workspace source file has been
|
|
// modified, created, or deleted since the cache was last built.
|
|
//
|
|
// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
|
|
// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
|
|
// lock itself (it would deadlock when called from BuildSystemPromptWithCache
|
|
// which already holds RLock or Lock).
|
|
func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
|
if cb.cachedAt.IsZero() {
|
|
return true
|
|
}
|
|
|
|
// Check tracked source files (bootstrap + memory).
|
|
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
|
|
return true
|
|
}
|
|
|
|
// --- Skill roots (workspace/global/builtin) ---
|
|
//
|
|
// For each root:
|
|
// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.
|
|
// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.
|
|
for _, root := range cb.skillRoots() {
|
|
if cb.fileChangedSince(root) {
|
|
return true
|
|
}
|
|
}
|
|
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// fileChangedSince returns true if a tracked source file has been modified,
|
|
// newly created, or deleted since the cache was built.
|
|
//
|
|
// Four cases:
|
|
// - existed at cache time, exists now -> check mtime
|
|
// - existed at cache time, gone now -> changed (deleted)
|
|
// - absent at cache time, exists now -> changed (created)
|
|
// - absent at cache time, gone now -> no change
|
|
func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
|
// Defensive: if existedAtCache was never initialized, treat as changed
|
|
// so the cache rebuilds rather than silently serving stale data.
|
|
if cb.existedAtCache == nil {
|
|
return true
|
|
}
|
|
|
|
existedBefore := cb.existedAtCache[path]
|
|
info, err := os.Stat(path)
|
|
existsNow := err == nil
|
|
|
|
if existedBefore != existsNow {
|
|
return true // file was created or deleted
|
|
}
|
|
if !existsNow {
|
|
return false // didn't exist before, doesn't exist now
|
|
}
|
|
return info.ModTime().After(cb.cachedAt)
|
|
}
|
|
|
|
// errWalkStop is a sentinel error used to stop filepath.WalkDir early.
|
|
// Using a dedicated error (instead of fs.SkipAll) makes the early-exit
|
|
// intent explicit and avoids the nilerr linter warning that would fire
|
|
// if the callback returned nil when its err parameter is non-nil.
|
|
var errWalkStop = errors.New("walk stop")
|
|
|
|
// skillFilesChangedSince compares the current recursive skill file tree
|
|
// against the cache-time snapshot. Any create/delete/mtime drift invalidates
|
|
// the cache.
|
|
func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {
|
|
// Defensive: if the snapshot was never initialized, force rebuild.
|
|
if filesAtCache == nil {
|
|
return true
|
|
}
|
|
|
|
// Check cached files still exist and keep the same mtime.
|
|
for path, cachedMtime := range filesAtCache {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
// A previously tracked file disappeared (or became inaccessible):
|
|
// either way, cached skill summary may now be stale.
|
|
return true
|
|
}
|
|
if !info.ModTime().Equal(cachedMtime) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check no new files appeared under any skill root.
|
|
changed := false
|
|
for _, root := range skillRoots {
|
|
if strings.TrimSpace(root) == "" {
|
|
continue
|
|
}
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
// Treat unexpected walk errors as changed to avoid stale cache.
|
|
if !os.IsNotExist(walkErr) {
|
|
changed = true
|
|
return errWalkStop
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if _, ok := filesAtCache[path]; !ok {
|
|
changed = true
|
|
return errWalkStop
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if changed {
|
|
return true
|
|
}
|
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|
var sb strings.Builder
|
|
|
|
agentDefinition := cb.LoadAgentDefinition()
|
|
if agentDefinition.Agent != nil {
|
|
label := string(agentDefinition.Source)
|
|
if label == "" {
|
|
label = relativeWorkspacePath(cb.workspace, agentDefinition.Agent.Path)
|
|
}
|
|
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", label, agentDefinition.Agent.Body)
|
|
}
|
|
if agentDefinition.Soul != nil {
|
|
fmt.Fprintf(
|
|
&sb,
|
|
"## %s\n\n%s\n\n",
|
|
relativeWorkspacePath(cb.workspace, agentDefinition.Soul.Path),
|
|
agentDefinition.Soul.Content,
|
|
)
|
|
}
|
|
if agentDefinition.User != nil {
|
|
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "USER.md", agentDefinition.User.Content)
|
|
}
|
|
|
|
if agentDefinition.Source != AgentDefinitionSourceAgent {
|
|
filePath := filepath.Join(cb.workspace, "IDENTITY.md")
|
|
if data, err := os.ReadFile(filePath); err == nil {
|
|
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "IDENTITY.md", data)
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// buildDynamicContext returns a short dynamic context string with per-request info.
|
|
// This changes every request (time, session) so it is NOT part of the cached prompt.
|
|
// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
|
|
// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
|
|
// - OpenAI / Codex: prompt_cache_key for prefix-based caching
|
|
//
|
|
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
|
func formatCurrentSenderLine(senderID, senderDisplayName string) string {
|
|
senderID = strings.TrimSpace(senderID)
|
|
senderDisplayName = strings.TrimSpace(senderDisplayName)
|
|
|
|
switch {
|
|
case senderDisplayName != "" && senderID != "":
|
|
return fmt.Sprintf("Current sender: %s (ID: %s)", senderDisplayName, senderID)
|
|
case senderDisplayName != "":
|
|
return fmt.Sprintf("Current sender: %s", senderDisplayName)
|
|
case senderID != "":
|
|
return fmt.Sprintf("Current sender: %s", senderID)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildDynamicContext(
|
|
channel, chatID, senderID, senderDisplayName string,
|
|
) string {
|
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
|
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
|
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
|
|
|
|
if channel != "" && chatID != "" {
|
|
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
|
}
|
|
if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" {
|
|
fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine)
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (cb *ContextBuilder) BuildMessages(
|
|
history []providers.Message,
|
|
summary string,
|
|
currentMessage string,
|
|
media []string,
|
|
channel, chatID, senderID, senderDisplayName string,
|
|
activeSkills ...string,
|
|
) []providers.Message {
|
|
return cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
|
History: history,
|
|
Summary: summary,
|
|
CurrentMessage: currentMessage,
|
|
Media: media,
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
SenderID: senderID,
|
|
SenderDisplayName: senderDisplayName,
|
|
ActiveSkills: append([]string(nil), activeSkills...),
|
|
})
|
|
}
|
|
|
|
func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message {
|
|
messages := []providers.Message{}
|
|
|
|
// The default static part (identity, bootstrap, skills, memory) is cached
|
|
// locally to avoid repeated file I/O and string building on every call
|
|
// (fixes issue #607). Profile-customized static prompts are built on demand.
|
|
// Dynamic parts (time, session, summary) are appended per request unless the
|
|
// profile suppresses PicoClaw system context.
|
|
// Everything is sent as a single system message for provider compatibility:
|
|
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
|
|
// to the top-level "system" parameter in the Messages API request. A single
|
|
// contiguous system block makes this extraction straightforward.
|
|
// - Codex maps only the first system message to its instructions field.
|
|
// - OpenAI-compat passes messages through as-is.
|
|
staticPrompt, contentBlocks := cb.buildSystemPromptForRequest(req)
|
|
|
|
// Compose a single system message: static (cached) + dynamic + optional summary.
|
|
// Keeping all system content in one message ensures every provider adapter can
|
|
// extract it correctly (Anthropic adapter -> top-level system param,
|
|
// Codex -> instructions field).
|
|
//
|
|
// SystemParts carries the same content as structured blocks so that
|
|
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
|
// The static block is marked "ephemeral" — its prefix hash is stable
|
|
// across requests, enabling LLM-side KV cache reuse.
|
|
var stringParts []string
|
|
if strings.TrimSpace(staticPrompt) != "" {
|
|
stringParts = append(stringParts, staticPrompt)
|
|
}
|
|
|
|
promptParts := append([]PromptPart(nil), req.Overlays...)
|
|
if !req.SuppressDefaultSystemPrompt && !req.SuppressSkillContext {
|
|
activeSkills := append([]string(nil), req.ActiveSkills...)
|
|
if len(req.AllowedSkills) > 0 {
|
|
activeSkills = filterNamesByTurnProfile(activeSkills, req.AllowedSkills)
|
|
}
|
|
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(activeSkills)...)
|
|
}
|
|
if !req.SuppressDefaultSystemPrompt {
|
|
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil {
|
|
logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
} else {
|
|
promptParts = append(promptParts, contributedParts...)
|
|
}
|
|
}
|
|
|
|
if len(promptParts) > 0 {
|
|
for _, overlay := range sortPromptParts(promptParts) {
|
|
if strings.TrimSpace(overlay.Content) == "" {
|
|
continue
|
|
}
|
|
if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
|
|
logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{
|
|
"id": overlay.ID,
|
|
"layer": overlay.Layer,
|
|
"slot": overlay.Slot,
|
|
"source": overlay.Source.ID,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
stringParts = append(stringParts, overlay.Content)
|
|
contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil))
|
|
}
|
|
}
|
|
|
|
dynamicChars := 0
|
|
if !req.SuppressDefaultSystemPrompt {
|
|
// Build short dynamic context (time, runtime, session) — changes per request
|
|
dynamicCtx := cb.buildDynamicContext(
|
|
req.Channel,
|
|
req.ChatID,
|
|
req.SenderID,
|
|
req.SenderDisplayName,
|
|
)
|
|
dynamicChars = len(dynamicCtx)
|
|
runtimePart := PromptPart{
|
|
ID: "context.runtime",
|
|
Layer: PromptLayerContext,
|
|
Slot: PromptSlotRuntime,
|
|
Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"},
|
|
Title: "runtime context",
|
|
Content: dynamicCtx,
|
|
Stable: false,
|
|
Cache: PromptCacheNone,
|
|
}
|
|
stringParts = append(stringParts, dynamicCtx)
|
|
contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil))
|
|
|
|
if req.Summary != "" {
|
|
summaryPart := PromptPart{
|
|
ID: "context.summary",
|
|
Layer: PromptLayerContext,
|
|
Slot: PromptSlotSummary,
|
|
Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"},
|
|
Title: "context summary",
|
|
Content: fmt.Sprintf(
|
|
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
|
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
|
req.Summary,
|
|
),
|
|
Stable: false,
|
|
Cache: PromptCacheNone,
|
|
}
|
|
stringParts = append(stringParts, summaryPart.Content)
|
|
contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil))
|
|
}
|
|
}
|
|
|
|
if len(stringParts) == 0 && req.ToolUseFallback {
|
|
fallbackPart := PromptPart{
|
|
ID: "kernel.tool_use_fallback",
|
|
Layer: PromptLayerKernel,
|
|
Slot: PromptSlotIdentity,
|
|
Source: PromptSource{ID: PromptSourceKernel, Name: "tool_use_fallback"},
|
|
Title: "tool use fallback",
|
|
Content: toolUseSystemPromptRule(),
|
|
Stable: true,
|
|
Cache: PromptCacheEphemeral,
|
|
}
|
|
stringParts = append(stringParts, fallbackPart.Content)
|
|
contentBlocks = append(contentBlocks, promptContentBlock(fallbackPart, nil))
|
|
}
|
|
|
|
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
|
|
|
// Log system prompt summary for debugging (debug mode only).
|
|
// Read cachedSystemPrompt under lock to avoid a data race with
|
|
// concurrent InvalidateCache / BuildSystemPromptWithCache writes.
|
|
cb.systemPromptMutex.RLock()
|
|
isCached := cb.cachedSystemPrompt != ""
|
|
cb.systemPromptMutex.RUnlock()
|
|
|
|
logger.DebugCF("agent", "System prompt built",
|
|
map[string]any{
|
|
"static_chars": len(staticPrompt),
|
|
"dynamic_chars": dynamicChars,
|
|
"total_chars": len(fullSystemPrompt),
|
|
"has_summary": req.Summary != "",
|
|
"overlays": len(req.Overlays),
|
|
"cached": isCached,
|
|
})
|
|
|
|
// Log preview of system prompt (avoid logging huge content)
|
|
preview := utils.Truncate(fullSystemPrompt, 500)
|
|
logger.DebugCF("agent", "System prompt preview",
|
|
map[string]any{
|
|
"preview": preview,
|
|
})
|
|
|
|
history := sanitizeHistoryForProvider(req.History)
|
|
|
|
// Single system message containing all context — compatible with all providers.
|
|
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
|
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
|
if strings.TrimSpace(fullSystemPrompt) != "" {
|
|
messages = append(messages, providers.Message{
|
|
Role: "system",
|
|
Content: fullSystemPrompt,
|
|
SystemParts: contentBlocks,
|
|
})
|
|
}
|
|
|
|
// Add conversation history
|
|
messages = append(messages, history...)
|
|
|
|
// Add current user message. Media-only turns must still be preserved so
|
|
// multimodal providers receive the uploaded image even when the user sends
|
|
// no accompanying text.
|
|
if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
|
|
messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
|
|
}
|
|
if len(messages) == 0 {
|
|
messages = append(messages, userPromptMessage("", nil))
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
|
|
if len(history) == 0 {
|
|
return history
|
|
}
|
|
|
|
sanitized := make([]providers.Message, 0, len(history))
|
|
for _, msg := range history {
|
|
switch msg.Role {
|
|
case "system":
|
|
// Drop system messages from history. BuildMessages always
|
|
// constructs its own single system message (static + dynamic +
|
|
// summary); extra system messages would break providers that
|
|
// only accept one (Anthropic, Codex).
|
|
logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
|
|
continue
|
|
|
|
case "tool":
|
|
if len(sanitized) == 0 {
|
|
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
|
|
continue
|
|
}
|
|
// Walk backwards to find the nearest assistant message,
|
|
// skipping over any preceding tool messages (multi-tool-call case).
|
|
foundAssistant := false
|
|
for i := len(sanitized) - 1; i >= 0; i-- {
|
|
if sanitized[i].Role == "tool" {
|
|
continue
|
|
}
|
|
if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 {
|
|
foundAssistant = true
|
|
}
|
|
break
|
|
}
|
|
if !foundAssistant {
|
|
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
|
continue
|
|
}
|
|
sanitized = append(sanitized, msg)
|
|
|
|
case "assistant":
|
|
if len(msg.ToolCalls) > 0 {
|
|
if len(sanitized) == 0 {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping assistant tool-call turn at history start",
|
|
map[string]any{},
|
|
)
|
|
continue
|
|
}
|
|
prev := sanitized[len(sanitized)-1]
|
|
if prev.Role != "user" && prev.Role != "tool" {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping assistant tool-call turn with invalid predecessor",
|
|
map[string]any{"prev_role": prev.Role},
|
|
)
|
|
continue
|
|
}
|
|
}
|
|
sanitized = append(sanitized, msg)
|
|
|
|
default:
|
|
sanitized = append(sanitized, msg)
|
|
}
|
|
}
|
|
|
|
// Second pass: ensure every assistant message with tool_calls has matching
|
|
// tool result messages following it. This is required by strict providers
|
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
|
// be followed by tool messages responding to each 'tool_call_id'."
|
|
//
|
|
// Deduplication is scoped to the contiguous tool-result block that follows a
|
|
// single assistant tool-call message. Some providers legitimately reuse call
|
|
// IDs across separate turns (for example "call_0"), so global deduplication
|
|
// would incorrectly delete later valid tool results and leave an
|
|
// assistant(tool_calls) -> assistant sequence behind.
|
|
final := make([]providers.Message, 0, len(sanitized))
|
|
for i := 0; i < len(sanitized); i++ {
|
|
msg := sanitized[i]
|
|
|
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
|
invalidToolCallID := false
|
|
for _, tc := range msg.ToolCalls {
|
|
if tc.ID == "" {
|
|
invalidToolCallID = true
|
|
continue
|
|
}
|
|
expected[tc.ID] = false
|
|
}
|
|
|
|
block := make([]providers.Message, 0, len(expected))
|
|
seenInBlock := make(map[string]bool, len(expected))
|
|
j := i + 1
|
|
for ; j < len(sanitized); j++ {
|
|
next := sanitized[j]
|
|
if next.Role != "tool" {
|
|
break
|
|
}
|
|
if next.ToolCallID == "" {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping tool result without tool_call_id",
|
|
map[string]any{},
|
|
)
|
|
continue
|
|
}
|
|
if _, ok := expected[next.ToolCallID]; !ok {
|
|
logger.DebugCF("agent", "Dropping unexpected tool result", map[string]any{
|
|
"tool_call_id": next.ToolCallID,
|
|
})
|
|
continue
|
|
}
|
|
if seenInBlock[next.ToolCallID] {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping duplicate tool result in tool block",
|
|
map[string]any{
|
|
"tool_call_id": next.ToolCallID,
|
|
},
|
|
)
|
|
continue
|
|
}
|
|
seenInBlock[next.ToolCallID] = true
|
|
expected[next.ToolCallID] = true
|
|
block = append(block, next)
|
|
}
|
|
|
|
allFound := !invalidToolCallID
|
|
if invalidToolCallID {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping assistant message with empty tool_call_id",
|
|
map[string]any{},
|
|
)
|
|
}
|
|
for toolCallID, found := range expected {
|
|
if !found {
|
|
allFound = false
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping assistant message with incomplete tool results",
|
|
map[string]any{
|
|
"missing_tool_call_id": toolCallID,
|
|
"expected_count": len(expected),
|
|
"found_count": len(block),
|
|
},
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allFound {
|
|
i = j - 1
|
|
continue
|
|
}
|
|
|
|
final = append(final, msg)
|
|
final = append(final, block...)
|
|
i = j - 1
|
|
continue
|
|
}
|
|
|
|
if msg.Role == "tool" {
|
|
logger.DebugCF(
|
|
"agent",
|
|
"Dropping orphaned tool message after validation",
|
|
map[string]any{
|
|
"tool_call_id": msg.ToolCallID,
|
|
},
|
|
)
|
|
continue
|
|
}
|
|
|
|
final = append(final, msg)
|
|
}
|
|
|
|
return final
|
|
}
|
|
|
|
func (cb *ContextBuilder) AddToolResult(
|
|
messages []providers.Message,
|
|
toolCallID, toolName, result string,
|
|
) []providers.Message {
|
|
messages = append(messages, providers.Message{
|
|
Role: "tool",
|
|
Content: result,
|
|
ToolCallID: toolCallID,
|
|
})
|
|
return messages
|
|
}
|
|
|
|
func (cb *ContextBuilder) AddAssistantMessage(
|
|
messages []providers.Message,
|
|
content string,
|
|
toolCalls []map[string]any,
|
|
) []providers.Message {
|
|
msg := providers.Message{
|
|
Role: "assistant",
|
|
Content: content,
|
|
}
|
|
// Always add assistant message, whether or not it has tool calls
|
|
messages = append(messages, msg)
|
|
return messages
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
|
ordered := cb.ResolveActiveSkillsForContext(skillNames)
|
|
if len(ordered) == 0 {
|
|
return ""
|
|
}
|
|
|
|
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
|
if strings.TrimSpace(content) == "" {
|
|
return ""
|
|
}
|
|
|
|
return fmt.Sprintf(`# Active Skills
|
|
|
|
The following skills are active for this request. Follow them when relevant.
|
|
|
|
%s`, content)
|
|
}
|
|
|
|
func (cb *ContextBuilder) ResolveActiveSkillsForContext(skillNames []string) []string {
|
|
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var ordered []string
|
|
seen := make(map[string]struct{}, len(skillNames))
|
|
for _, name := range skillNames {
|
|
canonical, ok := cb.ResolveSkillName(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := seen[canonical]; exists {
|
|
continue
|
|
}
|
|
seen[canonical] = struct{}{}
|
|
ordered = append(ordered, canonical)
|
|
}
|
|
if len(ordered) == 0 {
|
|
return nil
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
|
|
skillsText := cb.buildActiveSkillsContext(skillNames)
|
|
if strings.TrimSpace(skillsText) == "" {
|
|
return nil
|
|
}
|
|
|
|
return []PromptPart{
|
|
{
|
|
ID: "capability.active_skills",
|
|
Layer: PromptLayerCapability,
|
|
Slot: PromptSlotActiveSkill,
|
|
Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"},
|
|
Title: "active skills",
|
|
Content: skillsText,
|
|
Stable: false,
|
|
Cache: PromptCacheNone,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (cb *ContextBuilder) ListSkillNames() []string {
|
|
if cb.skillsLoader == nil {
|
|
return nil
|
|
}
|
|
|
|
allSkills := cb.skillsLoader.ListSkills()
|
|
names := make([]string, 0, len(allSkills))
|
|
for _, skill := range allSkills {
|
|
names = append(names, skill.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || cb.skillsLoader == nil {
|
|
return "", false
|
|
}
|
|
|
|
for _, skill := range cb.skillsLoader.ListSkills() {
|
|
if strings.EqualFold(skill.Name, name) {
|
|
return skill.Name, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// GetSkillsInfo returns information about loaded skills.
|
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
|
allSkills := cb.skillsLoader.ListSkills()
|
|
skillNames := make([]string, 0, len(allSkills))
|
|
for _, s := range allSkills {
|
|
skillNames = append(skillNames, s.Name)
|
|
}
|
|
return map[string]any{
|
|
"total": len(allSkills),
|
|
"available": len(allSkills),
|
|
"names": skillNames,
|
|
}
|
|
}
|