Files
picoclaw/pkg/agent/context.go
T

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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&apos;",
)
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,
}
}