fix(agent): restore static prompt cache compatibility
This commit is contained in:
parent
d9a9e30876
commit
2b66547714
1 changed files with 215 additions and 37 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -40,6 +42,13 @@ type ContextBuilder struct {
|
||||||
settings ContextRuntimeSettings
|
settings ContextRuntimeSettings
|
||||||
bootstrapMu sync.RWMutex
|
bootstrapMu sync.RWMutex
|
||||||
bootstrapCache map[string]string
|
bootstrapCache map[string]string
|
||||||
|
|
||||||
|
// Cache for static system prompt to avoid rebuilding on every call.
|
||||||
|
// Dynamic per-request data (time/session/summary) is appended in BuildMessages.
|
||||||
|
systemPromptMutex sync.RWMutex
|
||||||
|
cachedSystemPrompt string
|
||||||
|
cachedAt time.Time
|
||||||
|
existedAtCache map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
|
@ -94,6 +103,7 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
||||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||||
cb.tools = registry
|
cb.tools = registry
|
||||||
|
cb.InvalidateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) SetRuntimeSettings(settings ContextRuntimeSettings) {
|
func (cb *ContextBuilder) SetRuntimeSettings(settings ContextRuntimeSettings) {
|
||||||
|
|
@ -136,9 +146,7 @@ func (cb *ContextBuilder) SetRuntimeSettings(settings ContextRuntimeSettings) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
|
||||||
|
|
||||||
// Build tools section dynamically
|
// Build tools section dynamically
|
||||||
toolsSection := cb.buildToolsSection()
|
toolsSection := cb.buildToolsSection()
|
||||||
|
|
@ -147,12 +155,6 @@ func (cb *ContextBuilder) getIdentity() string {
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
## Current Time
|
|
||||||
%s
|
|
||||||
|
|
||||||
## Runtime
|
|
||||||
%s
|
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: %s
|
Your workspace is at: %s
|
||||||
- Memory: %s/memory/MEMORY.md
|
- Memory: %s/memory/MEMORY.md
|
||||||
|
|
@ -167,8 +169,10 @@ Your workspace is at: %s
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||||
|
|
||||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
|
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
|
||||||
|
4. **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.`,
|
||||||
|
workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildToolsSection() string {
|
func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
|
|
@ -231,6 +235,148 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
||||||
return strings.Join(parts, "\n\n---\n\n")
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildSystemPromptWithCache returns the cached static system prompt if available
|
||||||
|
// and tracked source files have not changed.
|
||||||
|
func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
||||||
|
cached := cb.cachedSystemPrompt
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
|
cb.systemPromptMutex.Lock()
|
||||||
|
defer cb.systemPromptMutex.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring write lock.
|
||||||
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
||||||
|
return cb.cachedSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
baseline := cb.buildCacheBaseline()
|
||||||
|
prompt := cb.BuildSystemPrompt()
|
||||||
|
cb.cachedSystemPrompt = prompt
|
||||||
|
cb.cachedAt = baseline.maxMtime
|
||||||
|
cb.existedAtCache = baseline.existed
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears cached static prompt state.
|
||||||
|
func (cb *ContextBuilder) InvalidateCache() {
|
||||||
|
cb.systemPromptMutex.Lock()
|
||||||
|
defer cb.systemPromptMutex.Unlock()
|
||||||
|
|
||||||
|
cb.cachedSystemPrompt = ""
|
||||||
|
cb.cachedAt = time.Time{}
|
||||||
|
cb.existedAtCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourcePaths returns tracked file paths for static prompt invalidation.
|
||||||
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
|
return []string{
|
||||||
|
filepath.Join(cb.workspace, "AGENTS.md"),
|
||||||
|
filepath.Join(cb.workspace, "SOUL.md"),
|
||||||
|
filepath.Join(cb.workspace, "USER.md"),
|
||||||
|
filepath.Join(cb.workspace, "IDENTITY.md"),
|
||||||
|
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheBaseline struct {
|
||||||
|
existed map[string]bool
|
||||||
|
maxMtime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
|
skillsDir := filepath.Join(cb.workspace, "skills")
|
||||||
|
allPaths := append(cb.sourcePaths(), skillsDir)
|
||||||
|
|
||||||
|
existed := make(map[string]bool, len(allPaths))
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr == nil && !d.IsDir() {
|
||||||
|
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
|
||||||
|
maxMtime = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if maxMtime.IsZero() {
|
||||||
|
// Keep non-zero baseline for empty workspace so future file creation
|
||||||
|
// always has a later mtime.
|
||||||
|
maxMtime = time.Unix(1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheBaseline{existed: existed, maxMtime: maxMtime}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFilesChangedLocked checks whether tracked files changed since cache.
|
||||||
|
// Caller must hold at least a read lock on systemPromptMutex.
|
||||||
|
func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
|
if cb.cachedAt.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range cb.sourcePaths() {
|
||||||
|
if cb.fileChangedSince(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsDir := filepath.Join(cb.workspace, "skills")
|
||||||
|
if cb.fileChangedSince(skillsDir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return skillFilesModifiedSince(skillsDir, cb.cachedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
||||||
|
if cb.existedAtCache == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
existedBefore := cb.existedAtCache[path]
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
existsNow := err == nil
|
||||||
|
if existedBefore != existsNow {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !existsNow {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.ModTime().After(cb.cachedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errWalkStop = errors.New("walk stop")
|
||||||
|
|
||||||
|
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
|
||||||
|
changed := false
|
||||||
|
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr == nil && !d.IsDir() {
|
||||||
|
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
||||||
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadBootstrapFiles(sessionKey string) string {
|
func (cb *ContextBuilder) LoadBootstrapFiles(sessionKey string) string {
|
||||||
if cb.settings.BootstrapSnapshotEnabled && strings.TrimSpace(sessionKey) != "" {
|
if cb.settings.BootstrapSnapshotEnabled && strings.TrimSpace(sessionKey) != "" {
|
||||||
cb.bootstrapMu.RLock()
|
cb.bootstrapMu.RLock()
|
||||||
|
|
@ -265,6 +411,18 @@ func (cb *ContextBuilder) LoadBootstrapFiles(sessionKey string) string {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) buildDynamicContext(channel, chatID 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)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildMessages(
|
func (cb *ContextBuilder) BuildMessages(
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
summary string,
|
summary string,
|
||||||
|
|
@ -292,34 +450,25 @@ func (cb *ContextBuilder) BuildMessagesForSession(
|
||||||
channel, chatID string,
|
channel, chatID string,
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
_ = sessionKey
|
||||||
|
|
||||||
systemPrompt := cb.BuildSystemPromptForSession(sessionKey)
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
||||||
|
|
||||||
// Add Current Session info if provided
|
stringParts := []string{staticPrompt, dynamicCtx}
|
||||||
if channel != "" && chatID != "" {
|
contentBlocks := []providers.ContentBlock{
|
||||||
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
|
||||||
|
{Type: "text", Text: dynamicCtx},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log system prompt summary for debugging (debug mode only)
|
|
||||||
logger.DebugCF("agent", "System prompt built",
|
|
||||||
map[string]any{
|
|
||||||
"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]any{
|
|
||||||
"preview": preview,
|
|
||||||
})
|
|
||||||
|
|
||||||
if summary != "" {
|
if summary != "" {
|
||||||
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
summaryText := 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",
|
||||||
|
summary,
|
||||||
|
)
|
||||||
|
stringParts = append(stringParts, summaryText)
|
||||||
|
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cb.settings.MemoryVectorEnabled && strings.TrimSpace(currentMessage) != "" {
|
if cb.settings.MemoryVectorEnabled && strings.TrimSpace(currentMessage) != "" {
|
||||||
|
|
@ -333,17 +482,40 @@ func (cb *ContextBuilder) BuildMessagesForSession(
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else if section := formatRetrievedMemoryContext(hits, cb.settings.MemoryVectorMaxChars); section != "" {
|
} else if section := formatRetrievedMemoryContext(hits, cb.settings.MemoryVectorMaxChars); section != "" {
|
||||||
systemPrompt += "\n\n---\n\n" + section
|
stringParts = append(stringParts, section)
|
||||||
|
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: section})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||||
|
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
isCached := cb.cachedSystemPrompt != ""
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
|
logger.DebugCF("agent", "System prompt built",
|
||||||
|
map[string]any{
|
||||||
|
"static_chars": len(staticPrompt),
|
||||||
|
"dynamic_chars": len(dynamicCtx),
|
||||||
|
"total_chars": len(fullSystemPrompt),
|
||||||
|
"has_summary": summary != "",
|
||||||
|
"cached": isCached,
|
||||||
|
})
|
||||||
|
|
||||||
|
preview := fullSystemPrompt
|
||||||
|
if len(preview) > 500 {
|
||||||
|
preview = preview[:500] + "... (truncated)"
|
||||||
|
}
|
||||||
|
logger.DebugCF("agent", "System prompt preview", map[string]any{"preview": preview})
|
||||||
|
|
||||||
history = sanitizeHistoryForProvider(history)
|
history = sanitizeHistoryForProvider(history)
|
||||||
history = cb.pruneHistoryForContext(history, systemPrompt)
|
history = cb.pruneHistoryForContext(history, fullSystemPrompt)
|
||||||
history = sanitizeHistoryForProvider(history)
|
history = sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: systemPrompt,
|
Content: fullSystemPrompt,
|
||||||
|
SystemParts: contentBlocks,
|
||||||
})
|
})
|
||||||
|
|
||||||
messages = append(messages, history...)
|
messages = append(messages, history...)
|
||||||
|
|
@ -572,6 +744,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
|
|
||||||
for _, msg := range history {
|
for _, msg := range history {
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
|
case "system":
|
||||||
|
// BuildMessages constructs a single authoritative system message.
|
||||||
|
// Keep provider input compatible by dropping system messages from history.
|
||||||
|
logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
|
||||||
|
continue
|
||||||
|
|
||||||
case "tool":
|
case "tool":
|
||||||
if pendingToolCalls == nil {
|
if pendingToolCalls == nil {
|
||||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue