Files
goclaw/internal/tools/context_file_interceptor.go
T
viettranx 08a2d95c0c feat: agent heartbeat system — periodic proactive check-ins (#245)
Phase 1 (Core):
- Migration 000022: agent_heartbeats, heartbeat_run_logs, agent_config_permissions tables
- HeartbeatStore + ConfigPermissionStore interfaces with PG implementations
- HeartbeatTicker: background poll → active hours filter → queue-aware skip → run → smart suppression → deliver/log
- Heartbeat tool: status/get/set/toggle/set_checklist/get_checklist/test/logs actions
- Permission check with wildcard scope matching + TTL cache (60s)
- RPC methods: heartbeat.get/set/toggle/test/logs/checklist.get/checklist.set
- HEARTBEAT.md routed via context file interceptor (read/write for both open + predefined agents)
- Session keys: agent:{id}:heartbeat or agent:{id}💓{ts} (isolated)
- PromptMinimal for heartbeat sessions (like cron/subagent)
- Event broadcasting + cache invalidation via bus (heartbeat + config_perms)
- Gateway wiring: ticker init, event wiring, graceful shutdown

Phase 2 (Integration):
- wakeMode: CronPayload.WakeHeartbeat triggers heartbeat after cron job completes
- Queue-aware: Scheduler.HasActiveSessionsForAgent() skips busy agents
- Stagger: deterministic FNV offset spreads heartbeats across interval
- lightContext: RunRequest.LightContext skips context files, only injects checklist
- System prompt distinguishes cron (user-scheduled tasks) vs heartbeat (autonomous monitoring)
2026-03-18 13:11:44 +07:00

463 lines
16 KiB
Go

package tools
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// protectedFileSet defines files that require group file writer permission in group chats.
// These files control the agent's identity and behavior — only allowlisted users can modify them.
var protectedFileSet = map[string]bool{
bootstrap.SoulFile: true,
bootstrap.IdentityFile: true,
bootstrap.AgentsFile: true,
bootstrap.UserFile: true,
bootstrap.UserPredefinedFile: true,
}
// contextFileSet is the set of filenames routed to the DB store.
// TOOLS.md excluded — not applicable.
var contextFileSet = map[string]bool{
bootstrap.SoulFile: true,
bootstrap.AgentsFile: true,
bootstrap.IdentityFile: true,
bootstrap.UserFile: true,
bootstrap.UserPredefinedFile: true,
bootstrap.BootstrapFile: true, // first-run file (deleted after completion)
bootstrap.HeartbeatFile: true, // agent-level heartbeat checklist
}
// isContextFile checks if a path refers to a workspace-root context file.
// Handles both relative ("SOUL.md") and absolute ("/workspace/SOUL.md") paths.
// Also matches absolute paths under per-user workspace subdirectories
// (e.g. "/workspace/<userID>/USER.md") since context files at any depth
// under the workspace root should be routed to DB.
func isContextFile(path, workspace string) (fileName string, ok bool) {
base := filepath.Base(path)
if !contextFileSet[base] {
return "", false
}
// Relative root-level: "SOUL.md", "./SOUL.md"
dir := filepath.Dir(path)
if dir == "." || dir == "/" || dir == "" {
return base, true
}
// Absolute path under workspace (includes per-user subdirectories):
// "/workspace/SOUL.md" or "/workspace/<userID>/SOUL.md"
if workspace != "" && filepath.IsAbs(path) {
cleanPath := filepath.Clean(path)
cleanWS := filepath.Clean(workspace)
if strings.HasPrefix(filepath.Dir(cleanPath), cleanWS) {
return base, true
}
}
return "", false
}
const defaultContextCacheTTL = 5 * time.Minute
// ContextFileInterceptor routes context file reads/writes to the agent store.
// Keeps SOUL.md, IDENTITY.md etc. in Postgres.
// Routes based on agent type: "open" → all per-user, "predefined" → only USER.md per-user.
type ContextFileInterceptor struct {
agentStore store.AgentStore
workspace string // workspace root for matching absolute paths
agentCache cache.Cache[[]store.AgentContextFileData] // agent-level files, keyed by agentID.String()
userCache cache.Cache[[]store.AgentContextFileData] // user-level files, keyed by "agentID:userID"
ttl time.Duration
groupWriterCache *store.GroupWriterCache // nil = use direct DB call (backward compat)
}
// NewContextFileInterceptor creates an interceptor backed by the given agent store.
// Cache implementations are injected (in-memory or Redis) so callers control the backend.
func NewContextFileInterceptor(
as store.AgentStore,
workspace string,
agentCache, userCache cache.Cache[[]store.AgentContextFileData],
) *ContextFileInterceptor {
return &ContextFileInterceptor{
agentStore: as,
workspace: workspace,
agentCache: agentCache,
userCache: userCache,
ttl: defaultContextCacheTTL,
}
}
// SetGroupWriterCache sets the shared cache for group writer permission checks (defense-in-depth).
func (b *ContextFileInterceptor) SetGroupWriterCache(c *store.GroupWriterCache) {
b.groupWriterCache = c
}
// ReadFile attempts to read a context file from the DB (with cache).
// Routes based on agent type from context:
// - "open": all files per-user → fallback to agent-level
// - "predefined": USER.md + BOOTSTRAP.md per-user → all others agent-level
//
// Returns (content, true, nil) if handled, or ("", false, nil) if not a context file.
func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) {
fileName, ok := isContextFile(path, b.workspace)
if !ok {
return "", false, nil
}
agentID := store.AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return "", false, nil // no agent context
}
userID := store.UserIDFromContext(ctx)
agentType := store.AgentTypeFromContext(ctx)
// Open agent: ALL files per-user → fallback to agent-level
if agentType == store.AgentTypeOpen && userID != "" {
content, handled, err := b.readUserFile(ctx, agentID, userID, fileName)
if err != nil {
return "", handled, err
}
if content != "" {
return content, handled, nil
}
// User file not found → fall back to agent-level template
return b.readAgentFile(ctx, agentID, fileName)
}
// Predefined agent: USER.md and BOOTSTRAP.md per-user
if agentType == store.AgentTypePredefined && userID != "" && (fileName == bootstrap.UserFile || fileName == bootstrap.BootstrapFile) {
content, handled, err := b.readUserFile(ctx, agentID, userID, fileName)
if err != nil {
return "", handled, err
}
if content != "" {
return content, handled, nil
}
return b.readAgentFile(ctx, agentID, fileName)
}
// Predefined agent: block reads of shared identity files (SOUL.md, IDENTITY.md, AGENTS.md).
// These are already injected into the system prompt — allowing read_file would let the
// agent echo their full contents to users, leaking persona configuration.
if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile && fileName != bootstrap.BootstrapFile && fileName != bootstrap.HeartbeatFile {
return "", true, fmt.Errorf(
"this file (%s) is already loaded into your context. You don't need to read it again — refer to your system instructions instead.",
fileName,
)
}
// Default: agent-level
return b.readAgentFile(ctx, agentID, fileName)
}
func (b *ContextFileInterceptor) readAgentFile(ctx context.Context, agentID uuid.UUID, fileName string) (string, bool, error) {
for _, f := range b.cachedAgentFiles(ctx, agentID) {
if f.FileName == fileName {
return f.Content, true, nil
}
}
return "", true, nil
}
func (b *ContextFileInterceptor) readUserFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) (string, bool, error) {
for _, f := range b.cachedUserFiles(ctx, agentID, userID) {
if f.FileName == fileName {
return f.Content, true, nil
}
}
return "", true, nil
}
// WriteFile attempts to write a context file to the DB.
// Routes based on agent type:
// - "open": all files per-user
// - "predefined": only USER.md per-user, others agent-level
// - BOOTSTRAP.md with empty content → delete (first-run completed)
//
// Returns (true, nil) if handled, or (false, nil) if not a context file.
func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content string) (bool, error) {
fileName, ok := isContextFile(path, b.workspace)
if !ok {
return false, nil
}
agentID := store.AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return false, nil // no agent context
}
userID := store.UserIDFromContext(ctx)
agentType := store.AgentTypeFromContext(ctx)
// Permission check: protected files in group context require allowlist membership.
// Exception: during bootstrap onboarding (BOOTSTRAP.md still exists for this user),
// USER.md writes are allowed so the bot can complete the first-run ritual.
if (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) && protectedFileSet[fileName] {
skipCheck := false
if fileName == bootstrap.UserFile && b.hasBootstrapFile(ctx, agentID, userID) {
skipCheck = true // onboarding in progress — allow USER.md write
}
if !skipCheck {
senderID := store.SenderIDFromContext(ctx)
if senderID != "" {
numericID := strings.SplitN(senderID, "|", 2)[0]
var isWriter bool
var err error
if b.groupWriterCache != nil {
isWriter, err = b.groupWriterCache.IsWriter(ctx, agentID, userID, numericID)
} else {
isWriter, err = b.agentStore.IsGroupFileWriter(ctx, agentID, userID, numericID)
}
if err != nil {
slog.Warn("security.group_file_writer_check_failed",
"error", err, "sender", numericID, "file", fileName, "group", userID)
// fail open: allow write if DB check fails
} else if !isWriter {
return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. Ask a group file writer to add you with /addwriter", fileName)
}
}
// senderID empty = system context (cron, subagent) → fail open
}
}
// BOOTSTRAP.md deletion: empty content = first-run completed → delete row.
// Must come BEFORE the predefined write block so bootstrap completion works
// for both open and predefined agents.
if fileName == bootstrap.BootstrapFile && content == "" && userID != "" {
err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName)
if err == nil {
b.invalidateUser(agentID, userID)
}
return true, err
}
// Predefined agent: block writes to shared files (only USER.md + HEARTBEAT.md allowed).
// Exception: SOUL.md is allowed when self_evolve is enabled (style/tone evolution).
if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile && fileName != bootstrap.HeartbeatFile {
allowSoulEvolve := fileName == bootstrap.SoulFile && store.SelfEvolveFromContext(ctx)
if !allowSoulEvolve {
return true, fmt.Errorf(
"this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+
"Only the agent owner can edit it from the management dashboard.",
fileName,
)
}
// SOUL.md with self_evolve: write to agent-level (shared across all users)
slog.Info("self-evolve: SOUL.md updated",
"agent_id", agentID,
"user_id", userID,
)
err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content)
if err == nil {
b.InvalidateAgent(agentID)
}
return true, err
}
// Open agent: all files per-user
if agentType == store.AgentTypeOpen && userID != "" {
err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content)
if err == nil {
b.invalidateUser(agentID, userID)
}
return true, err
}
// Predefined agent: only USER.md per-user
if agentType == store.AgentTypePredefined && userID != "" && fileName == bootstrap.UserFile {
err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content)
if err == nil {
b.invalidateUser(agentID, userID)
}
return true, err
}
// Default: agent-level
err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content)
if err == nil {
b.InvalidateAgent(agentID)
}
return true, err
}
// LoadContextFiles loads context files for a specific user+agent combination.
// Used by the agent loop to dynamically resolve context files for system prompt.
// Uses the same agentCache/userCache as ReadFile — invalidated on WriteFile and pubsub events.
func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile {
// Open agent: all files from user_context_files
if agentType == store.AgentTypeOpen && userID != "" {
files := b.cachedUserFiles(ctx, agentID, userID)
var result []bootstrap.ContextFile
for _, f := range files {
if f.Content == "" {
continue
}
result = append(result, bootstrap.ContextFile{
Path: f.FileName,
Content: f.Content,
})
}
if len(result) > 0 {
return result
}
// No user files yet → fall through to agent-level
}
// Predefined agent: agent files + override USER.md from user
if agentType == store.AgentTypePredefined && userID != "" {
agentFiles := b.cachedAgentFiles(ctx, agentID)
userFiles := b.cachedUserFiles(ctx, agentID, userID)
// Build user file map for override lookup
userMap := make(map[string]string, len(userFiles))
for _, f := range userFiles {
if f.Content != "" {
userMap[f.FileName] = f.Content
}
}
var result []bootstrap.ContextFile
for _, f := range agentFiles {
content := f.Content
// Override with user version if available
if uc, ok := userMap[f.FileName]; ok {
content = uc
}
if content == "" {
continue
}
result = append(result, bootstrap.ContextFile{
Path: f.FileName,
Content: content,
})
}
// Include user-only files not present at agent level
// (e.g. BOOTSTRAP.md — seeded per-user for onboarding, not at agent level)
agentFileSet := make(map[string]bool, len(agentFiles))
for _, f := range agentFiles {
agentFileSet[f.FileName] = true
}
for _, f := range userFiles {
if !agentFileSet[f.FileName] && f.Content != "" {
result = append(result, bootstrap.ContextFile{
Path: f.FileName,
Content: f.Content,
})
}
}
return result
}
// Fallback: agent-level only
agentFiles := b.cachedAgentFiles(ctx, agentID)
var result []bootstrap.ContextFile
for _, f := range agentFiles {
if f.Content == "" {
continue
}
result = append(result, bootstrap.ContextFile{
Path: f.FileName,
Content: f.Content,
})
}
return result
}
// cachedAgentFiles returns agent-level context files, using agentCache.
// Same cache used by readAgentFile — invalidated by WriteFile and pubsub events.
func (b *ContextFileInterceptor) cachedAgentFiles(ctx context.Context, agentID uuid.UUID) []store.AgentContextFileData {
key := agentID.String()
if files, ok := b.agentCache.Get(ctx, key); ok {
return files
}
files, err := b.agentStore.GetAgentContextFiles(ctx, agentID)
if err != nil {
return nil
}
b.agentCache.Set(ctx, key, files, b.ttl)
return files
}
// cachedUserFiles returns user-level context files, using userCache.
// Same cache used by readUserFile — invalidated by WriteFile and pubsub events.
func (b *ContextFileInterceptor) cachedUserFiles(ctx context.Context, agentID uuid.UUID, userID string) []store.AgentContextFileData {
key := agentID.String() + ":" + userID
if files, ok := b.userCache.Get(ctx, key); ok {
return files
}
files, err := b.agentStore.GetUserContextFiles(ctx, agentID, userID)
if err != nil {
return nil
}
// Convert to AgentContextFileData for unified cache storage
cached := make([]store.AgentContextFileData, len(files))
for i, f := range files {
cached[i] = store.AgentContextFileData{
AgentID: f.AgentID,
FileName: f.FileName,
Content: f.Content,
}
}
b.userCache.Set(ctx, key, cached, b.ttl)
return cached
}
// InvalidateAgent clears the cache for a specific agent (called from event handler).
func (b *ContextFileInterceptor) InvalidateAgent(agentID uuid.UUID) {
ctx := context.Background()
b.agentCache.Delete(ctx, agentID.String())
// Also clear user caches for this agent
b.userCache.DeleteByPrefix(ctx, agentID.String()+":")
}
// InvalidateAll clears all cached entries.
func (b *ContextFileInterceptor) InvalidateAll() {
ctx := context.Background()
b.agentCache.Clear(ctx)
b.userCache.Clear(ctx)
}
// hasBootstrapFile checks if BOOTSTRAP.md still exists in user_context_files,
// indicating the user is still in onboarding. Used to exempt USER.md writes
// from group permission checks during the first-run ritual.
func (b *ContextFileInterceptor) hasBootstrapFile(ctx context.Context, agentID uuid.UUID, userID string) bool {
content, _, err := b.readUserFile(ctx, agentID, userID, bootstrap.BootstrapFile)
return err == nil && content != ""
}
// InvalidateUser clears the per-user cache for a specific agent+user combination.
func (b *ContextFileInterceptor) InvalidateUser(agentID uuid.UUID, userID string) {
b.invalidateUser(agentID, userID)
}
func (b *ContextFileInterceptor) invalidateUser(agentID uuid.UUID, userID string) {
b.userCache.Delete(context.Background(), agentID.String()+":"+userID)
}
// normalizeToRelative strips the workspace prefix from an absolute path,
// returning a workspace-relative path for consistent DB storage.
// e.g. "/home/user/workspace/SOUL.md" → "SOUL.md"
func normalizeToRelative(path, workspace string) string {
if workspace == "" || !filepath.IsAbs(path) {
return path
}
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(path))
if err != nil || strings.HasPrefix(rel, "..") {
return path // outside workspace, return as-is
}
return rel
}