perf(agent): avoid reloading bootstrap files on cache hits

This commit is contained in:
xiwuqi 2026-03-26 22:40:30 -05:00
parent 9d6a445bb1
commit 72863cf8bc

View file

@ -45,6 +45,11 @@ type ContextBuilder struct {
// build time. This catches nested file creations/deletions/mtime changes // build time. This catches nested file creations/deletions/mtime changes
// that may not update the top-level skill root directory mtime. // that may not update the top-level skill root directory mtime.
skillFilesAtCache map[string]time.Time skillFilesAtCache map[string]time.Time
// sourcePathsAtCache stores the active non-skill tracked paths captured
// when the cache was built, so cache hits do not need to re-detect the
// bootstrap file family.
sourcePathsAtCache []string
} }
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
@ -209,6 +214,7 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
cb.cachedAt = baseline.maxMtime cb.cachedAt = baseline.maxMtime
cb.existedAtCache = baseline.existed cb.existedAtCache = baseline.existed
cb.skillFilesAtCache = baseline.skillFiles cb.skillFilesAtCache = baseline.skillFiles
cb.sourcePathsAtCache = append(cb.sourcePathsAtCache[:0], baseline.sourcePaths...)
logger.DebugCF("agent", "System prompt cached", logger.DebugCF("agent", "System prompt cached",
map[string]any{ map[string]any{
@ -229,6 +235,7 @@ func (cb *ContextBuilder) InvalidateCache() {
cb.cachedAt = time.Time{} cb.cachedAt = time.Time{}
cb.existedAtCache = nil cb.existedAtCache = nil
cb.skillFilesAtCache = nil cb.skillFilesAtCache = nil
cb.sourcePathsAtCache = nil
logger.DebugCF("agent", "System prompt cache invalidated", nil) logger.DebugCF("agent", "System prompt cache invalidated", nil)
} }
@ -237,10 +244,25 @@ func (cb *ContextBuilder) InvalidateCache() {
// invalidation (bootstrap files + memory). Skill roots are handled separately // invalidation (bootstrap files + memory). Skill roots are handled separately
// because they require both directory-level and recursive file-level checks. // because they require both directory-level and recursive file-level checks.
func (cb *ContextBuilder) sourcePaths() []string { func (cb *ContextBuilder) sourcePaths() []string {
agentDefinition := cb.LoadAgentDefinition() agentPath := filepath.Join(cb.workspace, string(AgentDefinitionSourceAgent))
paths := agentDefinition.trackedPaths(cb.workspace) paths := []string{
paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md")) agentPath,
return uniquePaths(paths) filepath.Join(cb.workspace, "SOUL.md"),
filepath.Join(cb.workspace, "USER.md"),
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
}
// Cache invalidation only needs to know which bootstrap path family is active.
// Avoid loading/parsing AGENT.md on every cache hit; existence is sufficient
// because the structured format always wins when present.
if _, err := os.Stat(agentPath); err == nil {
return paths
}
return append(paths,
filepath.Join(cb.workspace, string(AgentDefinitionSourceAgents)),
filepath.Join(cb.workspace, "IDENTITY.md"),
)
} }
// skillRoots returns all skill root directories that can affect // skillRoots returns all skill root directories that can affect
@ -262,6 +284,7 @@ func (cb *ContextBuilder) skillRoots() []string {
type cacheBaseline struct { type cacheBaseline struct {
existed map[string]bool existed map[string]bool
skillFiles map[string]time.Time skillFiles map[string]time.Time
sourcePaths []string
maxMtime time.Time maxMtime time.Time
} }
@ -270,9 +293,10 @@ type cacheBaseline struct {
// Called under write lock when the cache is built. // Called under write lock when the cache is built.
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
skillRoots := cb.skillRoots() skillRoots := cb.skillRoots()
sourcePaths := cb.sourcePaths()
// All paths whose existence we track: source files + all skill roots. // All paths whose existence we track: source files + all skill roots.
allPaths := append(cb.sourcePaths(), skillRoots...) allPaths := append(append([]string(nil), sourcePaths...), skillRoots...)
existed := make(map[string]bool, len(allPaths)) existed := make(map[string]bool, len(allPaths))
skillFiles := make(map[string]time.Time) skillFiles := make(map[string]time.Time)
@ -312,7 +336,12 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
maxMtime = time.Unix(1, 0) maxMtime = time.Unix(1, 0)
} }
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime} return cacheBaseline{
existed: existed,
skillFiles: skillFiles,
sourcePaths: sourcePaths,
maxMtime: maxMtime,
}
} }
// sourceFilesChangedLocked checks whether any workspace source file has been // sourceFilesChangedLocked checks whether any workspace source file has been
@ -328,7 +357,10 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
} }
// Check tracked source files (bootstrap + memory). // Check tracked source files (bootstrap + memory).
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) { if len(cb.sourcePathsAtCache) == 0 {
return true
}
if slices.ContainsFunc(cb.sourcePathsAtCache, cb.fileChangedSince) {
return true return true
} }