mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 12:10:53 +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)
158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
// Package bootstrap loads workspace persona/context files and injects them
|
|
// into the agent's system prompt. Matching TS agents/workspace.ts + bootstrap-files.ts.
|
|
//
|
|
// Bootstrap files are loaded from the workspace directory at startup:
|
|
//
|
|
// AGENTS.md — operating instructions (every session)
|
|
// SOUL.md — persona, tone, boundaries
|
|
// USER.md — user profile
|
|
// IDENTITY.md— agent name, emoji, creature, vibe
|
|
// TOOLS.md — local tool notes
|
|
// BOOTSTRAP.md— first-run ritual (deleted after completion)
|
|
// MEMORY.md — long-term curated memory
|
|
package bootstrap
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Bootstrap filenames (matching TS workspace.ts constants).
|
|
const (
|
|
AgentsFile = "AGENTS.md"
|
|
SoulFile = "SOUL.md"
|
|
ToolsFile = "TOOLS.md"
|
|
IdentityFile = "IDENTITY.md"
|
|
UserFile = "USER.md"
|
|
UserPredefinedFile = "USER_PREDEFINED.md"
|
|
BootstrapFile = "BOOTSTRAP.md"
|
|
DelegationFile = "DELEGATION.md"
|
|
TeamFile = "TEAM.md"
|
|
AvailabilityFile = "AVAILABILITY.md"
|
|
HeartbeatFile = "HEARTBEAT.md"
|
|
MemoryFile = "MEMORY.md"
|
|
MemoryAltFile = "memory.md"
|
|
MemoryJSONFile = "MEMORY.json"
|
|
)
|
|
|
|
// standardFiles is the ordered list of bootstrap files to load.
|
|
var standardFiles = []string{
|
|
AgentsFile,
|
|
SoulFile,
|
|
ToolsFile,
|
|
IdentityFile,
|
|
UserFile,
|
|
BootstrapFile,
|
|
}
|
|
|
|
// minimalAllowlist is the set of files loaded for subagent/cron sessions.
|
|
// Matching TS MINIMAL_BOOTSTRAP_ALLOWLIST.
|
|
var minimalAllowlist = map[string]bool{
|
|
AgentsFile: true,
|
|
ToolsFile: true,
|
|
}
|
|
|
|
// File represents a workspace bootstrap file loaded from disk.
|
|
type File struct {
|
|
Name string // filename (e.g. "AGENTS.md")
|
|
Path string // absolute path
|
|
Content string // file content (empty if missing)
|
|
Missing bool // true if file doesn't exist on disk
|
|
}
|
|
|
|
// ContextFile is the truncated version ready for system prompt injection.
|
|
// Matches TS EmbeddedContextFile type.
|
|
type ContextFile struct {
|
|
Path string // display path (e.g. "SOUL.md")
|
|
Content string // truncated content
|
|
}
|
|
|
|
// LoadWorkspaceFiles reads all recognized bootstrap files from a workspace directory.
|
|
// Files are returned in a fixed order matching the TS implementation.
|
|
// Missing files are included with Missing=true and empty Content.
|
|
func LoadWorkspaceFiles(workspaceDir string) []File {
|
|
var files []File
|
|
|
|
// Load standard files
|
|
for _, name := range standardFiles {
|
|
f := loadFile(workspaceDir, name)
|
|
files = append(files, f)
|
|
}
|
|
|
|
// Load MEMORY.md (try MEMORY.md first, then memory.md)
|
|
memFile := loadFile(workspaceDir, MemoryFile)
|
|
if memFile.Missing {
|
|
memFile = loadFile(workspaceDir, MemoryAltFile)
|
|
}
|
|
files = append(files, memFile)
|
|
|
|
return files
|
|
}
|
|
|
|
// FilterForSession filters bootstrap files based on session type.
|
|
// Normal sessions get all files. Subagent and cron sessions get only
|
|
// AGENTS.md and TOOLS.md (minimal mode), matching TS filterBootstrapFilesForSession().
|
|
func FilterForSession(files []File, sessionKey string) []File {
|
|
if !IsSubagentSession(sessionKey) && !IsCronSession(sessionKey) && !IsHeartbeatSession(sessionKey) {
|
|
return files
|
|
}
|
|
|
|
var filtered []File
|
|
for _, f := range files {
|
|
if minimalAllowlist[f.Name] {
|
|
filtered = append(filtered, f)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// IsSubagentSession checks if a session key indicates a subagent session.
|
|
// Session key format: agent:{agentId}:{rest}
|
|
// Subagent sessions have "subagent:" in the rest part.
|
|
func IsSubagentSession(sessionKey string) bool {
|
|
rest := sessionRest(sessionKey)
|
|
return strings.HasPrefix(strings.ToLower(rest), "subagent:")
|
|
}
|
|
|
|
// IsCronSession checks if a session key indicates a cron session.
|
|
// Session key format: agent:{agentId}:{rest}
|
|
// Cron sessions have "cron:" in the rest part.
|
|
func IsCronSession(sessionKey string) bool {
|
|
rest := sessionRest(sessionKey)
|
|
return strings.HasPrefix(strings.ToLower(rest), "cron:")
|
|
}
|
|
|
|
// IsHeartbeatSession checks if a session key indicates a heartbeat session.
|
|
func IsHeartbeatSession(sessionKey string) bool {
|
|
rest := sessionRest(sessionKey)
|
|
return strings.HasPrefix(rest, "heartbeat")
|
|
}
|
|
|
|
// IsTeamSession checks if a session key indicates a team-dispatched task session.
|
|
// Session key format: agent:{agentId}:team:{teamID}:{chatID}
|
|
// Team sessions have "team:" in the rest part.
|
|
func IsTeamSession(sessionKey string) bool {
|
|
rest := sessionRest(sessionKey)
|
|
return strings.HasPrefix(strings.ToLower(rest), "team:")
|
|
}
|
|
|
|
// sessionRest extracts the rest part after "agent:{agentId}:" from a session key.
|
|
func sessionRest(sessionKey string) string {
|
|
// Format: agent:{agentId}:{rest}
|
|
parts := strings.SplitN(sessionKey, ":", 3)
|
|
if len(parts) < 3 || parts[0] != "agent" {
|
|
return ""
|
|
}
|
|
return parts[2]
|
|
}
|
|
|
|
func loadFile(dir, name string) File {
|
|
path := filepath.Join(dir, name)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return File{Name: name, Path: path, Missing: true}
|
|
}
|
|
return File{Name: name, Path: path, Content: string(data), Missing: false}
|
|
}
|