mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
08a2d95c0c
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)
463 lines
16 KiB
Go
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
|
|
}
|