fix(teams): validate blocked_by terminal state + improve leader orchestration prompt

- Add terminal-state check in executeCreate(): reject blocked_by
  referencing completed/cancelled/failed tasks with actionable error
- Add full validation in executeUpdate(): batch query via GetTasksByIDs,
  check existence + team membership + terminal state
- Add GetTasksByIDs batch query to TeamStore interface + pg implementation
- Refactor: modularize gateway, skills store, and team tools into
  focused files
- Update TEAM.md leader prompt: prefer delegation, plan full task graph
  upfront, create tasks in order with blocked_by UUIDs
This commit is contained in:
viettranx committed 2026-03-15 23:16:16 +07:00
1 parent 633872cb98
commit e138ac7676
28 files changed
+5242 -4763

No files matched your search

+10 -444
View File
@@ -12,7 +12,6 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
@@ -29,17 +28,13 @@ import (
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tasks"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/browser"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -83,374 +78,37 @@ func runGateway() {
// Detect server IPs for output scrubbing (prevents IP leaks via web_fetch, exec, etc.)
tools.DetectServerIPs(context.Background())
// Create tool registry with all tools
toolsReg := tools.NewRegistry()
agentCfg := cfg.ResolveAgent("default")
// Sandbox manager (optional — routes tools through Docker containers)
var sandboxMgr sandbox.Manager
if sbCfg := cfg.Agents.Defaults.Sandbox; sbCfg != nil && sbCfg.Mode != "" && sbCfg.Mode != "off" {
if err := sandbox.CheckDockerAvailable(context.Background()); err != nil {
slog.Warn("sandbox disabled: Docker not available",
"configured_mode", sbCfg.Mode,
"error", err,
)
} else {
resolved := sbCfg.ToSandboxConfig()
sandboxMgr = sandbox.NewDockerManager(resolved)
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
}
}
// Register file tools + exec tool (with sandbox routing via FsBridge if enabled)
if sandboxMgr != nil {
toolsReg.Register(tools.NewSandboxedReadFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedWriteFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedListFilesTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedEditTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedExecTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
} else {
toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewEditTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace))
}
// Memory tools — PG-backed; always registered (PG memory is always available)
toolsReg.Register(tools.NewMemorySearchTool())
toolsReg.Register(tools.NewMemoryGetTool())
toolsReg.Register(tools.NewKnowledgeGraphSearchTool())
slog.Info("memory + knowledge graph tools registered (PG-backed)")
// Browser automation tool
var browserMgr *browser.Manager
if cfg.Tools.Browser.Enabled {
var opts []browser.Option
if cfg.Tools.Browser.RemoteURL != "" {
opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL))
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL)
} else {
opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless))
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
}
browserMgr = browser.New(opts...)
toolsReg.Register(browser.NewBrowserTool(browserMgr))
toolsReg, execApprovalMgr, mcpMgr, sandboxMgr, browserMgr, webFetchTool, permPE, toolPE, dataDir, agentCfg := setupToolRegistry(cfg, workspace, providerRegistry)
if browserMgr != nil {
defer browserMgr.Close()
}
// Web tools (web_search + web_fetch)
webSearchTool := tools.NewWebSearchTool(tools.WebSearchConfig{
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
DDGEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
})
if webSearchTool != nil {
toolsReg.Register(webSearchTool)
slog.Info("web_search tool enabled")
}
webFetchTool := tools.NewWebFetchTool(tools.WebFetchConfig{
Policy: cfg.Tools.WebFetch.Policy,
AllowedDomains: cfg.Tools.WebFetch.AllowedDomains,
BlockedDomains: cfg.Tools.WebFetch.BlockedDomains,
})
toolsReg.Register(webFetchTool)
slog.Info("web_fetch tool enabled", "policy", cfg.Tools.WebFetch.Policy, "blocked", len(cfg.Tools.WebFetch.BlockedDomains))
// Vision fallback tool (for non-vision providers like MiniMax)
toolsReg.Register(tools.NewReadImageTool(providerRegistry))
toolsReg.Register(tools.NewCreateImageTool(providerRegistry))
// Audio generation tool (MiniMax music + ElevenLabs sound effects)
toolsReg.Register(tools.NewCreateAudioTool(providerRegistry,
cfg.Tts.ElevenLabs.APIKey, cfg.Tts.ElevenLabs.BaseURL))
// TTS (text-to-speech) system
ttsMgr := setupTTS(cfg)
if ttsMgr != nil {
toolsReg.Register(tools.NewTtsTool(ttsMgr))
slog.Info("tts enabled", "provider", ttsMgr.PrimaryProvider(), "auto", string(ttsMgr.AutoMode()))
}
// Tool rate limiting (per session, sliding window)
if cfg.Tools.RateLimitPerHour > 0 {
toolsReg.SetRateLimiter(tools.NewToolRateLimiter(cfg.Tools.RateLimitPerHour))
slog.Info("tool rate limiting enabled", "per_hour", cfg.Tools.RateLimitPerHour)
}
// Credential scrubbing (enabled by default, can be disabled via config)
if cfg.Tools.ScrubCredentials != nil && !*cfg.Tools.ScrubCredentials {
toolsReg.SetScrubbing(false)
slog.Info("credential scrubbing disabled")
}
// MCP servers (config-based: shared across all agents)
var mcpMgr *mcpbridge.Manager
if len(cfg.Tools.McpServers) > 0 {
mcpMgr = mcpbridge.NewManager(toolsReg, mcpbridge.WithConfigs(cfg.Tools.McpServers))
if err := mcpMgr.Start(context.Background()); err != nil {
slog.Warn("mcp.startup_errors", "error", err)
}
if mcpMgr != nil {
defer mcpMgr.Stop()
slog.Info("MCP servers initialized", "configured", len(cfg.Tools.McpServers), "tools", len(mcpMgr.ToolNames()))
}
// Exec approval system — always active (deny patterns + safe bins + configurable ask mode)
var execApprovalMgr *tools.ExecApprovalManager
{
approvalCfg := tools.DefaultExecApprovalConfig()
// Override from user config (backward compat: explicit values take precedence)
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Security != "" {
approvalCfg.Security = tools.ExecSecurity(eaCfg.Security)
}
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Ask != "" {
approvalCfg.Ask = tools.ExecAskMode(eaCfg.Ask)
}
if len(cfg.Tools.ExecApproval.Allowlist) > 0 {
approvalCfg.Allowlist = cfg.Tools.ExecApproval.Allowlist
}
execApprovalMgr = tools.NewExecApprovalManager(approvalCfg)
// Wire approval to exec tools in the registry
if execTool, ok := toolsReg.Get("exec"); ok {
if aa, ok := execTool.(tools.ApprovalAware); ok {
aa.SetApprovalManager(execApprovalMgr, "default")
}
}
slog.Info("exec approval enabled", "security", string(approvalCfg.Security), "ask", string(approvalCfg.Ask))
}
// --- Enforcement: Policy engines ---
// Permission policy engine (role-based RPC access control)
permPE := permissions.NewPolicyEngine(cfg.Gateway.OwnerIDs)
// Tool policy engine (7-step tool filtering pipeline)
toolPE := tools.NewPolicyEngine(&cfg.Tools)
// Data directory for Phase 2 services
dataDir := cfg.ResolvedDataDir()
os.MkdirAll(dataDir, 0755)
// Block exec from accessing sensitive directories (data dir, .goclaw, config file).
// Prevents `cp /app/data/config.json workspace/` and similar exfiltration.
// Exception: .goclaw/skills-store/ is allowed (skills may contain executable scripts).
if execTool, ok := toolsReg.Get("exec"); ok {
if et, ok := execTool.(*tools.ExecTool); ok {
et.DenyPaths(dataDir, ".goclaw/")
et.AllowPathExemptions(".goclaw/skills-store/")
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
}
}
}
// Block filesystem tools from accessing internal system files within the workspace.
// Shared-workspace agents have workspace = dataDir root, exposing config.json,
// memory.db, .media/, delegate/ etc. via list_files/read_file.
// Non-shared agents are already isolated by resolvePath boundary check, but
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"memory/", ".media/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
if t, ok := rf.(*tools.ReadFileTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if wf, ok := toolsReg.Get("write_file"); ok {
if t, ok := wf.(*tools.WriteFileTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if lf, ok := toolsReg.Get("list_files"); ok {
if t, ok := lf.(*tools.ListFilesTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if ed, ok := toolsReg.Get("edit"); ok {
if t, ok := ed.(*tools.EditTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
// --- Store creation (Postgres) ---
if cfg.Database.PostgresDSN == "" {
slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
os.Exit(1)
}
var traceCollector *tracing.Collector
// Schema compatibility check: ensure DB schema matches this binary.
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
slog.Error("schema compatibility check failed", "error", err)
os.Exit(1)
}
storeCfg := store.StoreConfig{
PostgresDSN: cfg.Database.PostgresDSN,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
pgStores, pgErr := pg.NewPGStores(storeCfg)
if pgErr != nil {
slog.Error("failed to create PG stores", "error", pgErr)
os.Exit(1)
}
if pgStores.Tracing != nil {
traceCollector = tracing.NewCollector(pgStores.Tracing)
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
ids := make([]string, len(traceIDs))
for i, id := range traceIDs {
ids[i] = id.String()
}
msgBus.Broadcast(bus.Event{
Name: protocol.EventTraceUpdated,
Payload: map[string]any{"trace_ids": ids},
})
}
traceCollector.Start()
slog.Info("LLM tracing enabled")
}
pgStores, traceCollector, snapshotWorker := setupStoresAndTracing(cfg, dataDir, msgBus)
if traceCollector != nil {
defer traceCollector.Stop()
// OTel OTLP export: compiled via build tags. Build with 'go build -tags otel' to enable.
initOTelExporter(context.Background(), cfg, traceCollector)
}
// Start snapshot worker for hourly usage aggregation
if pgStores.Snapshots != nil {
snapshotWorker := tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots)
snapshotWorker.Start()
if snapshotWorker != nil {
defer snapshotWorker.Stop()
// Backfill historical data in background
go func() {
count, err := snapshotWorker.Backfill(context.Background())
if err != nil {
slog.Warn("snapshot backfill failed", "error", err)
} else if count > 0 {
slog.Info("snapshot backfill complete", "hours", count)
}
}()
}
// Redis cache: compiled via build tags. Build with 'go build -tags redis' to enable.
redisClient := initRedisClient(cfg)
defer shutdownRedis(redisClient)
// Wire cron config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
// Apply retry config via type assertion on the concrete cron store.
pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
_ = cronRetryCfg // config available; pg cron store reads it internally
if cfg.Cron.DefaultTimezone != "" {
pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
}
// Load secrets from config_secrets table before env overrides.
// Precedence: config.json → DB secrets → env vars (highest).
if pgStores.ConfigSecrets != nil {
if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
cfg.ApplyDBSecrets(secrets)
cfg.ApplyEnvOverrides()
slog.Info("config secrets loaded from DB", "count", len(secrets))
}
}
// Register providers from DB (overrides config providers).
if pgStores.Providers != nil {
dbGatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token, pgStores.MCP)
}
// Wire embedding provider to PGMemoryStore so IndexDocument generates vectors.
// Per-agent DB config takes priority over config file defaults.
if pgStores.Memory != nil {
memCfg := cfg.Agents.Defaults.Memory
if pgStores.Agents != nil {
if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil {
if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil {
memCfg = agentMemCfg
slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey)
}
}
}
if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil {
pgStores.Memory.SetEmbeddingProvider(embProvider)
slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model())
setupMemoryEmbeddings(cfg, pgStores, providerRegistry)
// Backfill embeddings for existing chunks that were stored without vectors.
type backfiller interface {
BackfillEmbeddings(ctx context.Context) (int, error)
}
if bf, ok := pgStores.Memory.(backfiller); ok {
go func() {
bgCtx := context.Background()
count, err := bf.BackfillEmbeddings(bgCtx)
if err != nil {
slog.Warn("memory embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("memory embeddings backfill complete", "chunks_updated", count)
}
}()
}
} else {
slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors")
}
}
// Load bootstrap files for default agent's system prompt from DB.
// Seeds if empty; falls back to filesystem as last resort.
var contextFiles []bootstrap.ContextFile
if pgStores.Agents != nil {
bgCtx := context.Background()
defaultAgent, agErr := pgStores.Agents.GetByKey(bgCtx, "default")
if agErr == nil {
dbFiles := bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
if len(dbFiles) > 0 {
contextFiles = dbFiles
slog.Info("bootstrap loaded from store", "count", len(dbFiles))
} else {
// DB empty → seed templates, then load
if _, seedErr := bootstrap.SeedToStore(bgCtx, pgStores.Agents, defaultAgent.ID, defaultAgent.AgentType); seedErr != nil {
slog.Warn("failed to seed bootstrap to store", "error", seedErr)
} else {
contextFiles = bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
slog.Info("bootstrap seeded and loaded from store", "count", len(contextFiles))
}
}
}
}
if len(contextFiles) == 0 {
// DB fallback: load from workspace filesystem
rawFiles := bootstrap.LoadWorkspaceFiles(workspace)
truncCfg := bootstrap.TruncateConfig{
MaxCharsPerFile: agentCfg.BootstrapMaxChars,
TotalMaxChars: agentCfg.BootstrapTotalMaxChars,
}
if truncCfg.MaxCharsPerFile <= 0 {
truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile
}
if truncCfg.TotalMaxChars <= 0 {
truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars
}
contextFiles = bootstrap.BuildContextFiles(rawFiles, truncCfg)
slog.Info("bootstrap loaded from filesystem", "count", len(contextFiles))
}
// Debug: log bootstrap file loading results
{
var loadedNames []string
for _, cf := range contextFiles {
loadedNames = append(loadedNames, fmt.Sprintf("%s(%d)", cf.Path, len(cf.Content)))
}
slog.Info("bootstrap context files", "count", len(contextFiles), "files", loadedNames)
}
loadBootstrapFiles(pgStores, workspace, agentCfg)
// Subagent system
subagentMgr := setupSubagents(providerRegistry, cfg, msgBus, toolsReg, workspace, sandboxMgr)
@@ -504,99 +162,8 @@ func runGateway() {
slog.Info("subagent system enabled", "tools", []string{"spawn"})
}
// Skills loader + search tool
// Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/.
globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR")
if globalSkillsDir == "" {
globalSkillsDir = filepath.Join(dataDir, "skills")
}
// Bundled skills: shipped with the Docker image at /app/bundled-skills/.
// Lowest priority — managed (skills-store) and user-uploaded skills override these.
builtinSkillsDir := os.Getenv("GOCLAW_BUILTIN_SKILLS_DIR")
if builtinSkillsDir == "" {
builtinSkillsDir = "/app/bundled-skills"
}
skillsLoader := skills.NewLoader(workspace, globalSkillsDir, builtinSkillsDir)
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
toolsReg.Register(skillSearchTool)
toolsReg.Register(tools.NewUseSkillTool())
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills()))
// Wire skills-store directory into filesystem loader so agents
// can discover uploaded skills in their system prompt and BM25 search index.
if pgStores.Skills != nil {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
skillsLoader.SetManagedDir(storeDirs[0])
slog.Info("skills-store directory wired into loader", "dir", storeDirs[0])
// Seed system/bundled skills into DB
bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
if bundledSkillsDir == "" {
// Check common locations: Docker default, then local dev
for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
bundledSkillsDir = candidate
break
}
}
}
if bundledSkillsDir != "" {
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
seeder := skills.NewSeeder(bundledSkillsDir, storeDirs[0], pgSkills)
seeded, skipped, seededSkills, err := seeder.Seed(context.Background())
if err != nil {
slog.Warn("system skills seed failed", "error", err)
} else {
if seeded > 0 {
slog.Info("system skills seeded", "seeded", seeded, "skipped", skipped)
}
// Check dependencies asynchronously — does not block startup.
// Emits WS events per-skill so UI updates in realtime.
if len(seededSkills) > 0 {
seeder.CheckDepsAsync(seededSkills, msgBus)
}
}
}
}
}
}
// Publish skill tool — lets agents register created skills in the database
if pgStores.Skills != nil {
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader))
slog.Info("publish_skill tool registered")
}
}
}
// Wire embedding-based skill search + per-agent access filtering
if pgStores.Skills != nil {
if sas, ok := pgStores.Skills.(store.SkillAccessStore); ok {
skillSearchTool.SetSkillAccessStore(sas)
}
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
memCfg := cfg.Agents.Defaults.Memory
if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil {
pgSkills.SetEmbeddingProvider(embProvider)
skillSearchTool.SetEmbeddingSearcher(pgSkills, embProvider)
slog.Info("skill embeddings enabled", "provider", embProvider.Name())
// Backfill embeddings for existing skills
go func() {
count, err := pgSkills.BackfillSkillEmbeddings(context.Background())
if err != nil {
slog.Warn("skill embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("skill embeddings backfill complete", "skills_updated", count)
}
}()
}
}
}
skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus)
_ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity
// Cron tool (agent-facing, matching TS cron-tool.ts)
toolsReg.Register(tools.NewCronTool(pgStores.Cron))
@@ -691,7 +258,6 @@ func runGateway() {
// for immediate cache invalidation on agents.files.set.
var contextFileInterceptor *tools.ContextFileInterceptor
// Set agent store for tools_invoke context injection + wire extras
if pgStores.Agents != nil {
server.SetAgentStore(pgStores.Agents)
@@ -1021,7 +587,7 @@ func runGateway() {
agentRouter.UpdateActivity(sessionKey, agentEvent.RunID, phase, tool, iteration)
}
}
}
// Clear activity on terminal events
if agentEvent.Type == protocol.AgentEventRunCompleted || agentEvent.Type == protocol.AgentEventRunFailed {
+9 -1108
View File
File diff suppressed because it is too large. Load diff
+838
View File
@@ -0,0 +1,838 @@
package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// handleSubagentAnnounce processes subagent announce messages: bypass debounce,
// inject into parent agent session (matching TS subagent-announce.ts pattern).
// Returns true if the message was handled (caller should continue).
func handleSubagentAnnounce(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
msgBus *bus.MessageBus,
getAnnounceMu func(string) *sync.Mutex,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "subagent:")) {
return false
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChannelType := resolveChannelType(channelMgr, origChannel)
parentAgent := msg.Metadata["parent_agent"]
if parentAgent == "" {
parentAgent = "default"
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
}
if origChannel == "" || msg.ChatID == "" {
slog.Warn("subagent announce: missing origin", "sender", msg.SenderID)
return true
}
// Use exact origin session key if available (WS uses non-standard format).
sessionKey := msg.Metadata["origin_session_key"]
if sessionKey == "" {
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
}
slog.Info("subagent announce → scheduler (subagent lane)",
"subagent", msg.SenderID,
"label", msg.Metadata["subagent_label"],
"session", sessionKey,
)
// Extract parent trace context for announce linking
var parentTraceID, parentRootSpanID uuid.UUID
if tid := msg.Metadata["origin_trace_id"]; tid != "" {
parentTraceID, _ = uuid.Parse(tid)
}
if sid := msg.Metadata["origin_root_span_id"]; sid != "" {
parentRootSpanID, _ = uuid.Parse(sid)
}
// Group-scoped UserID for subagent announce (same logic as main lane).
announceUserID := msg.UserID
if origPeerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
announceUserID = fmt.Sprintf("group:%s:%s", origChannel, msg.ChatID)
}
// Build outbound metadata for topic/thread routing.
outMeta := buildAnnounceOutMeta(origLocalKey)
// Build request before goroutine to capture msg fields.
// WS channel has no outbound handler — media converted to markdown URLs
// and appended to the assistant response via ContentSuffix, which the
// agent loop applies BEFORE saving to session and emitting run.completed.
fwdMedia := msg.Media
contentSuffix := ""
if origChannel == "ws" && len(msg.Media) > 0 {
contentSuffix = mediaToMarkdownFromPaths(msg.Media, cfg)
fwdMedia = nil // WS: images delivered via ContentSuffix, not ForwardMedia
}
announceReq := agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
ForwardMedia: fwdMedia,
ContentSuffix: contentSuffix,
Channel: origChannel,
ChannelType: origChannelType,
ChatID: msg.ChatID,
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("announce-%s", msg.SenderID),
RunKind: "announce",
HideInput: true, // don't persist raw system message in chat history
Stream: false,
ParentTraceID: parentTraceID,
ParentRootSpanID: parentRootSpanID,
}
// Handle announce asynchronously with per-session serialization.
// The mutex ensures concurrent announces for the same session wait for
// each other, so each reads up-to-date session history.
go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest) {
mu := getAnnounceMu(sessionKey)
mu.Lock()
defer mu.Unlock()
outCh := sched.Schedule(ctx, scheduler.LaneSubagent, req)
outcome := <-outCh
if outcome.Err != nil {
if errors.Is(outcome.Err, context.Canceled) {
slog.Info("subagent announce: run cancelled", "subagent", senderID)
return
}
slog.Error("subagent announce: agent run failed", "error", outcome.Err)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: formatAgentError(outcome.Err),
Metadata: meta,
})
return
}
// Suppress empty/NO_REPLY (matching TS normalize-reply.ts / tokens.ts).
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
if isSilent && len(outcome.Result.Media) == 0 {
slog.Info("subagent announce: suppressed silent/empty reply",
"subagent", senderID,
"label", label,
)
return
}
// Deliver agent's reformulated response to origin channel.
announceContent := outcome.Result.Content
if isSilent {
announceContent = "" // suppress NO_REPLY text but still send media
}
outMsg := bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: announceContent,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq)
return true
}
// handleDelegateAnnounce processes delegate announce messages: bypass debounce,
// inject into parent agent session using the "delegate" lane.
// Returns true if the message was handled (caller should continue).
func handleDelegateAnnounce(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
msgBus *bus.MessageBus,
getAnnounceMu func(string) *sync.Mutex,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "delegate:")) {
return false
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChannelType := resolveChannelType(channelMgr, origChannel)
parentAgent := msg.Metadata["parent_agent"]
if parentAgent == "" {
parentAgent = "default"
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
}
if origChannel == "" || msg.ChatID == "" {
slog.Warn("delegate announce: missing origin", "sender", msg.SenderID)
return true
}
// Use exact origin session key if available (WS uses non-standard format).
sessionKey := msg.Metadata["origin_session_key"]
if sessionKey == "" {
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
}
slog.Info("delegate announce → scheduler (delegate lane)",
"delegation", msg.SenderID,
"target", msg.Metadata["target_agent"],
"session", sessionKey,
)
announceUserID := msg.UserID
if origPeerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
announceUserID = fmt.Sprintf("group:%s:%s", origChannel, msg.ChatID)
}
// Extract parent trace context for announce linking (same as subagent announce)
var parentTraceID, parentRootSpanID uuid.UUID
if tid := msg.Metadata["origin_trace_id"]; tid != "" {
parentTraceID, _ = uuid.Parse(tid)
}
if sid := msg.Metadata["origin_root_span_id"]; sid != "" {
parentRootSpanID, _ = uuid.Parse(sid)
}
// Build outbound metadata for topic/thread routing.
outMeta := buildAnnounceOutMeta(origLocalKey)
// WS channel has no outbound handler — media injected into session after run.
// WS channel has no outbound handler — media delivered via ContentSuffix.
fwdMedia := msg.Media
contentSuffix := ""
if origChannel == "ws" && len(msg.Media) > 0 {
contentSuffix = mediaToMarkdownFromPaths(msg.Media, cfg)
fwdMedia = nil // WS: images delivered via ContentSuffix, not ForwardMedia
}
announceReq := agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
ForwardMedia: fwdMedia,
ContentSuffix: contentSuffix,
Channel: origChannel,
ChannelType: origChannelType,
ChatID: msg.ChatID,
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("delegate-announce-%s", msg.Metadata["delegation_id"]),
RunKind: "announce",
HideInput: true, // don't persist raw system message in chat history
Stream: false,
ParentTraceID: parentTraceID,
ParentRootSpanID: parentRootSpanID,
}
// Same per-session serialization as subagent announce above.
go func(sessionKey, origCh, chatID, senderID string, meta map[string]string, req agent.RunRequest) {
mu := getAnnounceMu(sessionKey)
mu.Lock()
defer mu.Unlock()
outCh := sched.Schedule(ctx, scheduler.LaneDelegate, req)
outcome := <-outCh
if outcome.Err != nil {
if errors.Is(outcome.Err, context.Canceled) {
slog.Info("delegate announce: run cancelled", "delegation", senderID)
return
}
slog.Error("delegate announce: agent run failed", "error", outcome.Err)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: formatAgentError(outcome.Err),
Metadata: meta,
})
return
}
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
if isSilent && len(outcome.Result.Media) == 0 {
slog.Info("delegate announce: suppressed silent/empty reply", "delegation", senderID)
return
}
announceContent := outcome.Result.Content
if isSilent {
announceContent = "" // suppress NO_REPLY text but still send media
}
outMsg := bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: announceContent,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq)
return true
}
// handleHandoffAnnounce processes handoff announce messages: route initial message
// to target agent session using the "delegate" lane.
// Returns true if the message was handled (caller should continue).
func handleHandoffAnnounce(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
msgBus *bus.MessageBus,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "handoff:")) {
return false
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChannelType := resolveChannelType(channelMgr, origChannel)
targetAgent := msg.AgentID
if targetAgent == "" {
targetAgent = cfg.ResolveDefaultAgentID()
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
}
if origChannel == "" || msg.ChatID == "" {
slog.Warn("handoff announce: missing origin", "sender", msg.SenderID)
return true
}
sessionKey := sessions.BuildScopedSessionKey(targetAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, targetAgent, origChannel, msg.ChatID, origPeerKind)
slog.Info("handoff announce → scheduler (delegate lane)",
"handoff", msg.SenderID,
"to", targetAgent,
"session", sessionKey,
)
announceUserID := msg.UserID
if origPeerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
announceUserID = fmt.Sprintf("group:%s:%s", origChannel, msg.ChatID)
}
outMeta := buildAnnounceOutMeta(origLocalKey)
outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Channel: origChannel,
ChannelType: origChannelType,
ChatID: msg.ChatID,
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("handoff-%s", msg.Metadata["handoff_id"]),
Stream: false,
})
go func(origCh, chatID string, meta map[string]string) {
outcome := <-outCh
if outcome.Err != nil {
slog.Error("handoff announce: agent run failed", "error", outcome.Err)
return
}
if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
return
}
outMsg := bus.OutboundMessage{
Channel: origCh,
ChatID: chatID,
Content: outcome.Result.Content,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(origChannel, msg.ChatID, outMeta)
return true
}
// handleTeammateMessage processes teammate messages: bypass debounce, route to target
// agent session using the "delegate" lane, then announce result back to lead.
// Returns true if the message was handled (caller should continue).
func handleTeammateMessage(
ctx context.Context,
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
teamStore store.TeamStore,
agentStore store.AgentStore,
msgBus *bus.MessageBus,
postTurn tools.PostTurnProcessor,
taskRunSessions *sync.Map,
) bool {
if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "teammate:")) {
return false
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
origChatID := msg.Metadata["origin_chat_id"] // original chat (e.g. Telegram chat ID)
if origChatID == "" {
origChatID = msg.ChatID // fallback to inbound ChatID (team UUID for old dispatches)
}
origChannelType := resolveChannelType(channelMgr, origChannel)
targetAgent := msg.AgentID // team_message sets AgentID to the target agent key
if targetAgent == "" {
targetAgent = cfg.ResolveDefaultAgentID()
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerDirect)
}
if origChannel == "" || origChatID == "" {
slog.Warn("teammate message: missing origin — DROPPED",
"sender", msg.SenderID,
"target", targetAgent,
"origin_channel", origChannel,
"origin_chat_id", origChatID,
"user_id", msg.UserID,
)
return true
}
// Use isolated team session key so member execution doesn't share
// the user's direct chat session with this agent.
// Scoped per agent + team + chatID, matching workspace isolation.
sessionKey := sessions.BuildTeamSessionKey(targetAgent, msg.Metadata["team_id"], origChatID)
slog.Info("teammate message → scheduler (delegate lane)",
"from", msg.SenderID,
"to", targetAgent,
"session", sessionKey,
"team_task_id", msg.Metadata["team_task_id"],
)
announceUserID := msg.UserID
if origPeerKind == string(sessions.PeerGroup) && origChatID != "" {
announceUserID = fmt.Sprintf("group:%s:%s", origChannel, origChatID)
}
outMeta := buildAnnounceOutMeta(origLocalKey)
// Link member agent trace back to lead's trace for unified tracing.
var linkedTraceID uuid.UUID
if tid := msg.Metadata["origin_trace_id"]; tid != "" {
linkedTraceID, _ = uuid.Parse(tid)
}
// Track task → session so the subscriber can cancel on task cancellation.
taskIDStr := msg.Metadata["team_task_id"]
if taskIDStr != "" {
taskRunSessions.Store(taskIDStr, sessionKey)
}
outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Channel: origChannel,
ChannelType: origChannelType,
ChatID: origChatID,
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata["from_agent"], msg.Metadata["to_agent"]),
Stream: false,
TeamTaskID: msg.Metadata["team_task_id"],
TeamWorkspace: msg.Metadata["team_workspace"],
WorkspaceChatID: origChatID,
TeamID: msg.Metadata["team_id"],
LinkedTraceID: linkedTraceID,
})
go func(origCh, origChatID, senderID, taskID string, meta, inMeta map[string]string) {
// Lock renewal heartbeat: extend task lock every 10 min to prevent
// the ticker from recovering long-running tasks as stale.
var lockStop chan struct{}
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" && teamStore != nil {
teamTaskID, _ := uuid.Parse(taskIDStr)
teamID, _ := uuid.Parse(inMeta["team_id"])
if teamTaskID != uuid.Nil {
lockStop = make(chan struct{})
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := teamStore.RenewTaskLock(ctx, teamTaskID, teamID); err != nil {
slog.Warn("teammate lock renewal failed", "task_id", teamTaskID, "error", err)
return
}
slog.Debug("teammate lock renewed", "task_id", teamTaskID)
case <-lockStop:
return
case <-ctx.Done():
return
}
}
}()
}
}
outcome := <-outCh
// Clean up task → session tracking now that the agent has finished.
if taskID != "" {
taskRunSessions.Delete(taskID)
}
// Stop lock renewal now that the agent has finished.
if lockStop != nil {
close(lockStop)
}
// Auto-complete/fail the associated team task (v2 only).
// Cache team lookup — reused later for announce routing.
var cachedTeam *store.TeamData
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" {
teamTaskID, _ := uuid.Parse(taskIDStr)
teamID, _ := uuid.Parse(inMeta["team_id"])
if teamTaskID != uuid.Nil && teamStore != nil {
cachedTeam, _ = teamStore.GetTeam(ctx, teamID)
if cachedTeam != nil && isConsumerTeamV2(cachedTeam) {
// Check current task status — agent may have already updated it via tool.
currentTask, taskErr := teamStore.GetTask(ctx, teamTaskID)
alreadyTerminal := taskErr == nil && currentTask != nil &&
(currentTask.Status == store.TeamTaskStatusCompleted ||
currentTask.Status == store.TeamTaskStatusFailed ||
currentTask.Status == store.TeamTaskStatusCancelled)
if !alreadyTerminal {
toAgent := inMeta["to_agent"]
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
if outcome.Err != nil {
if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil {
slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err)
} else {
msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamTaskFailed,
Payload: protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
},
})
// FailTask also unblocks dependent tasks.
if postTurn != nil {
postTurn.DispatchUnblockedTasks(ctx, teamID)
}
}
} else {
result := outcome.Result.Content
if len(outcome.Result.Deliverables) > 0 {
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
}
if len(result) > 100_000 {
result = result[:100_000] + "\n[truncated]"
}
if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil {
slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err)
} else {
msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamTaskCompleted,
Payload: protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: toAgent,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
},
})
// Dispatch newly-unblocked dependent tasks.
if postTurn != nil {
postTurn.DispatchUnblockedTasks(ctx, teamID)
}
}
}
}
}
}
}
if outcome.Err != nil {
slog.Error("teammate message: agent run failed", "error", outcome.Err)
return
}
if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
return
}
// Announce result to lead agent (same pattern as subagent announce).
// The lead reformulates the result and presents to the user.
// Extract parent trace context so the announce run nests under the lead's trace.
var announceParentTraceID, announceParentRootSpanID uuid.UUID
if tid := inMeta["origin_trace_id"]; tid != "" {
announceParentTraceID, _ = uuid.Parse(tid)
}
if sid := inMeta["origin_root_span_id"]; sid != "" {
announceParentRootSpanID, _ = uuid.Parse(sid)
}
// Resolve lead from team — reuse cachedTeam to avoid duplicate DB call.
leadAgent := ""
if cachedTeam != nil {
if leadAg, err := agentStore.GetByID(ctx, cachedTeam.LeadAgentID); err == nil {
leadAgent = leadAg.AgentKey
}
} else if teamIDStr := inMeta["team_id"]; teamIDStr != "" {
if teamUUID, err := uuid.Parse(teamIDStr); err == nil {
if team, err := teamStore.GetTeam(ctx, teamUUID); err == nil {
if leadAg, err := agentStore.GetByID(ctx, team.LeadAgentID); err == nil {
leadAgent = leadAg.AgentKey
}
}
}
}
if leadAgent == "" {
leadAgent = inMeta["from_agent"]
}
if leadAgent == "" {
leadAgent = cfg.ResolveDefaultAgentID()
}
memberAgent := inMeta["to_agent"]
announceContent := fmt.Sprintf(
"[System Message] Team member %q completed task.\n\nResult:\n%s\n\n"+
"Present this result to the user. Any media files are forwarded automatically. Do NOT search for files — the result above contains all relevant information.",
memberAgent, outcome.Result.Content,
)
// Append team workspace path so lead can locate files without searching.
if ws := inMeta["team_workspace"]; ws != "" {
announceContent += fmt.Sprintf("\n[Team workspace: %s — use read_file with path relative to workspace root, e.g. read_file(path=\"teams/...\")]", ws)
}
// Route to the lead's session on the original channel/chat.
if origChatID == "" {
slog.Warn("teammate announce: no origin_chat_id, cannot announce to lead")
return
}
leadSessionKey := sessions.BuildScopedSessionKey(leadAgent, origCh, sessions.PeerDirect, origChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
announceReq := agent.RunRequest{
SessionKey: leadSessionKey,
Message: announceContent,
Channel: origCh,
ChatID: origChatID,
PeerKind: string(sessions.PeerDirect),
UserID: inMeta["origin_user_id"],
RunID: fmt.Sprintf("teammate-announce-%s", memberAgent),
RunKind: "announce",
HideInput: true,
Stream: false,
TeamID: inMeta["team_id"],
ParentTraceID: announceParentTraceID,
ParentRootSpanID: announceParentRootSpanID,
}
for _, mr := range outcome.Result.Media {
announceReq.ForwardMedia = append(announceReq.ForwardMedia, bus.MediaFile{
Path: mr.Path,
MimeType: mr.ContentType,
})
}
// Inject post-turn tracker for announce run (leader may create new tasks).
announcePtd := tools.NewPendingTeamDispatch()
announceCtx := tools.WithPendingTeamDispatch(ctx, announcePtd)
announceOutCh := sched.Schedule(announceCtx, scheduler.LaneSubagent, announceReq)
announceOutcome := <-announceOutCh
// Post-turn: dispatch pending team tasks created during announce.
if postTurn != nil {
for tid, tIDs := range announcePtd.Drain() {
if err := postTurn.ProcessPendingTasks(ctx, tid, tIDs); err != nil {
slog.Warn("post_turn(announce): failed", "team_id", tid, "error", err)
}
}
}
if announceOutcome.Err != nil {
slog.Error("teammate announce: lead run failed", "error", announceOutcome.Err)
return
}
isSilent := announceOutcome.Result.Content == "" || agent.IsSilentReply(announceOutcome.Result.Content)
if isSilent && len(announceOutcome.Result.Media) == 0 {
return
}
announceOut := announceOutcome.Result.Content
if isSilent {
announceOut = ""
}
outMsg := bus.OutboundMessage{
Channel: origCh,
ChatID: origChatID,
Content: announceOut,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, announceOutcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(origChannel, origChatID, msg.SenderID, taskIDStr, outMeta, msg.Metadata)
return true
}
// handleResetCommand processes /reset command: clears session history.
// Returns true if the message was handled (caller should continue).
func handleResetCommand(
msg bus.InboundMessage,
cfg *config.Config,
sessStore store.SessionStore,
) bool {
if msg.Metadata["command"] != "reset" {
return false
}
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect)
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
sessStore.Reset(sessionKey)
sessStore.Save(sessionKey)
providers.ResetCLISession("", sessionKey)
slog.Info("inbound: /reset command", "session", sessionKey)
return true
}
// handleStopCommand processes /stop and /stopall commands: cancel active runs for a session.
// Returns true if the message was handled (caller should continue).
func handleStopCommand(
msg bus.InboundMessage,
cfg *config.Config,
sched *scheduler.Scheduler,
sessStore store.SessionStore,
msgBus *bus.MessageBus,
) bool {
cmd := msg.Metadata["command"]
if cmd != "stop" && cmd != "stopall" {
return false
}
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect)
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
var threadID int
fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
if threadID > 0 {
sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
}
}
// sessStore is referenced in the original code but not used in this branch beyond
// session key construction; kept as parameter for API consistency.
_ = sessStore
var cancelled bool
if cmd == "stopall" {
cancelled = sched.CancelSession(sessionKey)
slog.Info("inbound: /stopall command", "session", sessionKey, "cancelled", cancelled)
} else {
cancelled = sched.CancelOneSession(sessionKey)
slog.Info("inbound: /stop command", "session", sessionKey, "cancelled", cancelled)
}
// Publish feedback so the channel can show the result.
var feedback string
if cancelled {
if cmd == "stopall" {
feedback = "All tasks stopped."
} else {
feedback = "Task stopped."
}
} else {
if cmd == "stopall" {
feedback = "No active tasks to stop."
} else {
feedback = "No active task to stop."
}
}
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: feedback,
Metadata: msg.Metadata,
})
return true
}
+422
View File
@@ -0,0 +1,422 @@
package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// processNormalMessage handles routing, scheduling, and response delivery for a single
// (possibly merged) inbound message. Called directly by the debouncer's flush callback.
func processNormalMessage(
ctx context.Context,
msg bus.InboundMessage,
agents *agent.Router,
cfg *config.Config,
sched *scheduler.Scheduler,
channelMgr *channels.Manager,
teamStore store.TeamStore,
quotaChecker *channels.QuotaChecker,
sessStore store.SessionStore,
agentStore store.AgentStore,
contactCollector *store.ContactCollector,
postTurn tools.PostTurnProcessor,
msgBus *bus.MessageBus,
) {
// Determine target agent via bindings or explicit AgentID
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
// Check handoff routing override
if teamStore != nil && msg.AgentID == "" {
if route, _ := teamStore.GetHandoffRoute(ctx, msg.Channel, msg.ChatID); route != nil {
agentID = route.ToAgentKey
slog.Info("inbound: handoff route active",
"channel", msg.Channel, "chat", msg.ChatID, "to", agentID)
}
}
agentLoop, err := agents.Get(agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
return
}
// Build session key based on scope config (matching TS buildAgentPeerSessionKey).
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect) // default to DM
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
// Forum topic: override session key to isolate per-topic history.
// TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
var topicID int
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
if topicID > 0 {
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
// DM thread: override session key to isolate per-thread history in private chats.
if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
var threadID int
fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
if threadID > 0 {
sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
}
}
// Group-scoped UserID: context files, memory, traces, and seeding scope.
// - Discord guilds: "guild:{guildID}:user:{senderID}" — per-user per-server,
// shared across all channels within the same server. Session key stays per-channel.
// - Other platforms: "group:{channel}:{chatID}" — shared by all users in the chat.
// Individual senderID is preserved in InboundMessage for pairing/dedup/mention gate.
userID := msg.UserID
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
if guildID := msg.Metadata["guild_id"]; guildID != "" && msg.SenderID != "" {
// Discord guild: per-user scope so each member has own profile
// across all channels in the same server.
userID = fmt.Sprintf("guild:%s:user:%s", guildID, msg.SenderID)
} else {
groupID := msg.ChatID
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
}
// Persist friendly names from channel metadata into session + user profile.
sessionMeta := extractSessionMetadata(msg, peerKind)
if len(sessionMeta) > 0 {
sessStore.SetSessionMetadata(sessionKey, sessionMeta)
if agentStore != nil {
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
}
}
}
// Auto-collect channel contacts for the contact selector.
if contactCollector != nil && msg.SenderID != "" {
senderNumericID := msg.SenderID
if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 {
senderNumericID = senderNumericID[:idx]
}
channelType := channelMgr.ChannelTypeForName(msg.Channel)
if channelType == "" {
channelType = msg.Channel // fallback to instance name
}
displayName := sessionMeta["display_name"]
username := sessionMeta["username"]
contactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind)
}
// --- Quota check ---
if quotaChecker != nil {
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
if !qResult.Allowed {
slog.Warn("security.quota_exceeded",
"user_id", userID,
"channel", msg.Channel,
"window", qResult.Window,
"used", qResult.Used,
"limit", qResult.Limit,
)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: formatQuotaExceeded(qResult),
Metadata: msg.Metadata,
})
return
}
quotaChecker.Increment(userID)
}
// Auto-clear followup reminders when user sends a message on a real channel.
// Fire-and-forget: don't block message processing.
if teamStore != nil && msg.Channel != tools.ChannelSystem && msg.Channel != tools.ChannelDelegate && msg.Channel != tools.ChannelDashboard {
go func(ch, cid string) {
if n, err := teamStore.ClearFollowupByScope(ctx, ch, cid); err != nil {
slog.Warn("auto-clear followup failed", "channel", ch, "chat_id", cid, "error", err)
} else if n > 0 {
slog.Info("auto-clear followup: cleared", "channel", ch, "chat_id", cid, "count", n)
}
}(msg.Channel, msg.ChatID)
}
slog.Info("inbound: scheduling message (main lane)",
"channel", msg.Channel,
"chat_id", msg.ChatID,
"peer_kind", peerKind,
"agent", agentID,
"session", sessionKey,
"user_id", userID,
)
// Enable streaming when the channel supports it (so agent emits chunk events).
// The channel decides per chat type via separate dm_stream / group_stream flags.
isGroup := peerKind == string(sessions.PeerGroup)
enableStream := channelMgr != nil && channelMgr.IsStreamingChannel(msg.Channel, isGroup)
// Group chats allow concurrent runs (multiple users can chat simultaneously).
maxConcurrent := 1
if peerKind == string(sessions.PeerGroup) {
maxConcurrent = 3
}
runID := fmt.Sprintf("inbound-%s-%s-%s", msg.Channel, msg.ChatID, uuid.NewString()[:8])
// Build outbound metadata for reply-to + thread routing BEFORE RegisterRun
// so block.reply handler can use it for routing intermediate messages.
outMeta := make(map[string]string)
if isGroup {
if mid := msg.Metadata["message_id"]; mid != "" {
outMeta["reply_to_message_id"] = mid
}
}
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key", "group_id"} {
if v := msg.Metadata[k]; v != "" {
outMeta[k] = v
}
}
// Register run with channel manager for streaming/reaction event forwarding.
// Use localKey (composite key with topic suffix) so streaming/reaction events
// route to the correct per-topic state in the channel.
messageID := msg.Metadata["message_id"]
chatIDForRun := msg.ChatID
if lk := msg.Metadata["local_key"]; lk != "" {
chatIDForRun = lk
}
blockReply := channelMgr != nil && channelMgr.ResolveBlockReply(msg.Channel, cfg.Gateway.BlockReply)
toolStatus := cfg.Gateway.ToolStatus == nil || *cfg.Gateway.ToolStatus // default true
if channelMgr != nil {
channelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, enableStream, blockReply, toolStatus)
}
// Group-aware system prompt: help the LLM adapt tone and behavior for group chats.
var extraPrompt string
if peerKind == string(sessions.PeerGroup) {
extraPrompt = "You are in a GROUP chat (multiple participants), not a private 1-on-1 DM.\n" +
"- Messages may include a [Chat messages since your last reply] section with recent group history. Each history line shows \"sender [time]: message\".\n" +
"- The current message includes a [From: sender_name] tag identifying who @mentioned you.\n" +
"- Keep responses concise and focused; long replies are disruptive in groups.\n" +
"- Address the group naturally. If the history shows a multi-person conversation, consider the full context before answering."
}
// Append per-topic system prompt (from group/topic config hierarchy).
if tsp := msg.Metadata["topic_system_prompt"]; tsp != "" {
if extraPrompt != "" {
extraPrompt += "\n\n"
}
extraPrompt += tsp
}
// Per-topic skill filter override (from group/topic config hierarchy).
var skillFilter []string
if ts := msg.Metadata["topic_skills"]; ts != "" {
skillFilter = strings.Split(ts, ",")
}
// Delegation announces carry media as ForwardMedia (not deleted, forwarded to output).
// User-uploaded media goes in Media (loaded as images for LLM, then deleted).
var reqMedia, fwdMedia []bus.MediaFile
if msg.Metadata["delegation_id"] != "" || msg.Metadata["subagent_id"] != "" {
fwdMedia = msg.Media
} else {
reqMedia = msg.Media
}
// Intent classify fast-path: when agent is busy on DM, classify user intent
// to detect status queries, cancel requests, or steer/new_task for mid-run injection.
// Only for DM (maxConcurrent=1) where messages queue behind the active run.
if maxConcurrent == 1 && agents.IsSessionBusy(sessionKey) {
if loop, ok := agentLoop.(*agent.Loop); ok && loop.Provider() != nil {
locale := msg.Metadata["locale"]
if locale == "" {
locale = "en"
}
intent := agent.ClassifyIntent(ctx, loop.Provider(), loop.Model(), msg.Content)
switch intent {
case agent.IntentStatusQuery:
status := agents.GetActivity(sessionKey)
reply := agent.FormatStatusReply(status, locale)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: reply,
Metadata: outMeta,
})
return
case agent.IntentCancel:
aborted := agents.AbortRunsForSession(sessionKey)
if len(aborted) > 0 {
slog.Info("inbound: cancelled runs via intent classify",
"session", sessionKey, "aborted", aborted)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: i18n.T(locale, i18n.MsgCancelledReply),
Metadata: outMeta,
})
}
return
case agent.IntentSteer, agent.IntentNewTask:
// Mid-run injection: inject into the running loop instead of queueing.
injected := agents.InjectMessage(sessionKey, agent.InjectedMessage{
Content: msg.Content,
UserID: userID,
})
if injected {
slog.Info("inbound: injected mid-run message",
"intent", string(intent), "session", sessionKey)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: i18n.T(locale, i18n.MsgInjectedAck),
Metadata: outMeta,
})
return
}
// Fallback: injection failed (channel full) → fall through to scheduler queue
slog.Info("inbound: injection failed, queueing as normal",
"intent", string(intent), "session", sessionKey)
}
}
}
// Inject post-turn dispatch tracker so team task creates are deferred.
ptd := tools.NewPendingTeamDispatch()
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
outCh := sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Media: reqMedia,
ForwardMedia: fwdMedia,
Channel: msg.Channel,
ChannelType: resolveChannelType(channelMgr, msg.Channel),
ChatID: msg.ChatID,
PeerKind: peerKind,
LocalKey: msg.Metadata["local_key"],
UserID: userID,
SenderID: msg.SenderID,
RunID: runID,
Stream: enableStream,
HistoryLimit: msg.HistoryLimit,
ToolAllow: msg.ToolAllow,
ExtraSystemPrompt: extraPrompt,
SkillFilter: skillFilter,
}, scheduler.ScheduleOpts{
MaxConcurrent: maxConcurrent,
})
// Handle result asynchronously to not block the flush callback.
go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) {
outcome := <-outCh
// Post-turn: dispatch pending team tasks created during this turn.
if postTurn != nil {
for teamID, taskIDs := range ptd.Drain() {
if err := postTurn.ProcessPendingTasks(ctx, teamID, taskIDs); err != nil {
slog.Warn("post_turn: failed", "team_id", teamID, "error", err)
}
}
}
// Clean up run tracking (in case HandleAgentEvent didn't fire for terminal events)
if channelMgr != nil {
channelMgr.UnregisterRun(rID)
}
if outcome.Err != nil {
// Don't send error for cancelled runs (/stop command) —
// publish empty outbound to clean up thinking/typing indicators.
if errors.Is(outcome.Err, context.Canceled) {
slog.Info("inbound: run cancelled", "channel", channel, "session", session)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
Metadata: meta,
})
return
}
slog.Error("inbound: agent run failed", "error", outcome.Err, "channel", channel)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: formatAgentError(outcome.Err),
Metadata: meta,
})
return
}
// Suppress empty/NO_REPLY responses (matching TS normalize-reply.ts).
// Still publish an empty outbound so channels can clean up placeholder/thinking indicators.
if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) {
slog.Info("inbound: suppressed silent/empty reply",
"channel", channel,
"chat_id", chatID,
"session", session,
)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
Metadata: meta,
})
return
}
// Dedup: if block replies were delivered and the final content matches the last
// block reply, suppress the final message to avoid duplicate delivery.
// Only applies when blockReply is enabled (otherwise nothing was delivered).
if blockReplyEnabled && outcome.Result.BlockReplies > 0 && outcome.Result.Content == outcome.Result.LastBlockReply && len(outcome.Result.Media) == 0 {
slog.Debug("inbound: dedup final message (matches last block reply)",
"channel", channel, "run_id", rID)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "",
Metadata: meta,
})
return
}
// Publish response back to the channel
outMsg := bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: outcome.Result.Content,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
// Auto-set followup when lead agent replies on a real channel with in_progress tasks.
if teamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelDelegate && channel != tools.ChannelDashboard {
go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, outcome.Result.Content)
}
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply, ptd)
}
+534
View File
@@ -0,0 +1,534 @@
package cmd
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/browser"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// setupToolRegistry creates the tool registry and registers all tools.
// Returns the registry, exec approval manager, MCP manager, sandbox manager,
// browser manager (caller must defer Close), web fetch tool, permission policy engine,
// tool policy engine, data directory, and resolved agent defaults.
func setupToolRegistry(
cfg *config.Config,
workspace string,
providerRegistry *providers.Registry,
) (
toolsReg *tools.Registry,
execApprovalMgr *tools.ExecApprovalManager,
mcpMgr *mcpbridge.Manager,
sandboxMgr sandbox.Manager,
browserMgr *browser.Manager,
webFetchTool *tools.WebFetchTool,
permPE *permissions.PolicyEngine,
toolPE *tools.PolicyEngine,
dataDir string,
agentCfg config.AgentDefaults,
) {
// Create tool registry with all tools
toolsReg = tools.NewRegistry()
agentCfg = cfg.ResolveAgent("default")
// Sandbox manager (optional — routes tools through Docker containers)
if sbCfg := cfg.Agents.Defaults.Sandbox; sbCfg != nil && sbCfg.Mode != "" && sbCfg.Mode != "off" {
if err := sandbox.CheckDockerAvailable(context.Background()); err != nil {
slog.Warn("sandbox disabled: Docker not available",
"configured_mode", sbCfg.Mode,
"error", err,
)
} else {
resolved := sbCfg.ToSandboxConfig()
sandboxMgr = sandbox.NewDockerManager(resolved)
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
}
}
// Register file tools + exec tool (with sandbox routing via FsBridge if enabled)
if sandboxMgr != nil {
toolsReg.Register(tools.NewSandboxedReadFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedWriteFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedListFilesTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedEditTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
toolsReg.Register(tools.NewSandboxedExecTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
} else {
toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewEditTool(workspace, agentCfg.RestrictToWorkspace))
toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace))
}
// Memory tools — PG-backed; always registered (PG memory is always available)
toolsReg.Register(tools.NewMemorySearchTool())
toolsReg.Register(tools.NewMemoryGetTool())
toolsReg.Register(tools.NewKnowledgeGraphSearchTool())
slog.Info("memory + knowledge graph tools registered (PG-backed)")
// Browser automation tool
if cfg.Tools.Browser.Enabled {
var opts []browser.Option
if cfg.Tools.Browser.RemoteURL != "" {
opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL))
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL)
} else {
opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless))
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
}
browserMgr = browser.New(opts...)
toolsReg.Register(browser.NewBrowserTool(browserMgr))
}
// Web tools (web_search + web_fetch)
webSearchTool := tools.NewWebSearchTool(tools.WebSearchConfig{
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
DDGEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
})
if webSearchTool != nil {
toolsReg.Register(webSearchTool)
slog.Info("web_search tool enabled")
}
webFetchTool = tools.NewWebFetchTool(tools.WebFetchConfig{
Policy: cfg.Tools.WebFetch.Policy,
AllowedDomains: cfg.Tools.WebFetch.AllowedDomains,
BlockedDomains: cfg.Tools.WebFetch.BlockedDomains,
})
toolsReg.Register(webFetchTool)
slog.Info("web_fetch tool enabled", "policy", cfg.Tools.WebFetch.Policy, "blocked", len(cfg.Tools.WebFetch.BlockedDomains))
// Vision fallback tool (for non-vision providers like MiniMax)
toolsReg.Register(tools.NewReadImageTool(providerRegistry))
toolsReg.Register(tools.NewCreateImageTool(providerRegistry))
// Audio generation tool (MiniMax music + ElevenLabs sound effects)
toolsReg.Register(tools.NewCreateAudioTool(providerRegistry,
cfg.Tts.ElevenLabs.APIKey, cfg.Tts.ElevenLabs.BaseURL))
// TTS (text-to-speech) system
ttsMgr := setupTTS(cfg)
if ttsMgr != nil {
toolsReg.Register(tools.NewTtsTool(ttsMgr))
slog.Info("tts enabled", "provider", ttsMgr.PrimaryProvider(), "auto", string(ttsMgr.AutoMode()))
}
// Tool rate limiting (per session, sliding window)
if cfg.Tools.RateLimitPerHour > 0 {
toolsReg.SetRateLimiter(tools.NewToolRateLimiter(cfg.Tools.RateLimitPerHour))
slog.Info("tool rate limiting enabled", "per_hour", cfg.Tools.RateLimitPerHour)
}
// Credential scrubbing (enabled by default, can be disabled via config)
if cfg.Tools.ScrubCredentials != nil && !*cfg.Tools.ScrubCredentials {
toolsReg.SetScrubbing(false)
slog.Info("credential scrubbing disabled")
}
// MCP servers (config-based: shared across all agents)
if len(cfg.Tools.McpServers) > 0 {
mcpMgr = mcpbridge.NewManager(toolsReg, mcpbridge.WithConfigs(cfg.Tools.McpServers))
if err := mcpMgr.Start(context.Background()); err != nil {
slog.Warn("mcp.startup_errors", "error", err)
}
slog.Info("MCP servers initialized", "configured", len(cfg.Tools.McpServers), "tools", len(mcpMgr.ToolNames()))
}
// Exec approval system — always active (deny patterns + safe bins + configurable ask mode)
{
approvalCfg := tools.DefaultExecApprovalConfig()
// Override from user config (backward compat: explicit values take precedence)
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Security != "" {
approvalCfg.Security = tools.ExecSecurity(eaCfg.Security)
}
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Ask != "" {
approvalCfg.Ask = tools.ExecAskMode(eaCfg.Ask)
}
if len(cfg.Tools.ExecApproval.Allowlist) > 0 {
approvalCfg.Allowlist = cfg.Tools.ExecApproval.Allowlist
}
execApprovalMgr = tools.NewExecApprovalManager(approvalCfg)
// Wire approval to exec tools in the registry
if execTool, ok := toolsReg.Get("exec"); ok {
if aa, ok := execTool.(tools.ApprovalAware); ok {
aa.SetApprovalManager(execApprovalMgr, "default")
}
}
slog.Info("exec approval enabled", "security", string(approvalCfg.Security), "ask", string(approvalCfg.Ask))
}
// --- Enforcement: Policy engines ---
// Permission policy engine (role-based RPC access control)
permPE = permissions.NewPolicyEngine(cfg.Gateway.OwnerIDs)
// Tool policy engine (7-step tool filtering pipeline)
toolPE = tools.NewPolicyEngine(&cfg.Tools)
// Data directory for Phase 2 services
dataDir = cfg.ResolvedDataDir()
os.MkdirAll(dataDir, 0755)
// Block exec from accessing sensitive directories (data dir, .goclaw, config file).
// Prevents `cp /app/data/config.json workspace/` and similar exfiltration.
// Exception: .goclaw/skills-store/ is allowed (skills may contain executable scripts).
if execTool, ok := toolsReg.Get("exec"); ok {
if et, ok := execTool.(*tools.ExecTool); ok {
et.DenyPaths(dataDir, ".goclaw/")
et.AllowPathExemptions(".goclaw/skills-store/")
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
}
}
}
// Block filesystem tools from accessing internal system files within the workspace.
// Shared-workspace agents have workspace = dataDir root, exposing config.json,
// memory.db, .media/, delegate/ etc. via list_files/read_file.
// Non-shared agents are already isolated by resolvePath boundary check, but
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"memory/", ".media/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
if t, ok := rf.(*tools.ReadFileTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if wf, ok := toolsReg.Get("write_file"); ok {
if t, ok := wf.(*tools.WriteFileTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if lf, ok := toolsReg.Get("list_files"); ok {
if t, ok := lf.(*tools.ListFilesTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
if ed, ok := toolsReg.Get("edit"); ok {
if t, ok := ed.(*tools.EditTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
return
}
// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config.
// Exits the process on unrecoverable errors (missing DSN, schema mismatch, store creation failure).
func setupStoresAndTracing(
cfg *config.Config,
dataDir string,
msgBus *bus.MessageBus,
) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
// --- Store creation (Postgres) ---
if cfg.Database.PostgresDSN == "" {
slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
os.Exit(1)
}
var traceCollector *tracing.Collector
// Schema compatibility check: ensure DB schema matches this binary.
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
slog.Error("schema compatibility check failed", "error", err)
os.Exit(1)
}
storeCfg := store.StoreConfig{
PostgresDSN: cfg.Database.PostgresDSN,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
pgStores, pgErr := pg.NewPGStores(storeCfg)
if pgErr != nil {
slog.Error("failed to create PG stores", "error", pgErr)
os.Exit(1)
}
if pgStores.Tracing != nil {
traceCollector = tracing.NewCollector(pgStores.Tracing)
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
ids := make([]string, len(traceIDs))
for i, id := range traceIDs {
ids[i] = id.String()
}
msgBus.Broadcast(bus.Event{
Name: protocol.EventTraceUpdated,
Payload: map[string]any{"trace_ids": ids},
})
}
traceCollector.Start()
slog.Info("LLM tracing enabled")
}
// Start snapshot worker for hourly usage aggregation
var snapshotWorker *tracing.SnapshotWorker
if pgStores.Snapshots != nil {
snapshotWorker = tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots)
snapshotWorker.Start()
// Backfill historical data in background
go func() {
count, err := snapshotWorker.Backfill(context.Background())
if err != nil {
slog.Warn("snapshot backfill failed", "error", err)
} else if count > 0 {
slog.Info("snapshot backfill complete", "hours", count)
}
}()
}
// Wire cron config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
// Apply retry config via type assertion on the concrete cron store.
pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
_ = cronRetryCfg // config available; pg cron store reads it internally
if cfg.Cron.DefaultTimezone != "" {
pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
}
// Load secrets from config_secrets table before env overrides.
// Precedence: config.json → DB secrets → env vars (highest).
if pgStores.ConfigSecrets != nil {
if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
cfg.ApplyDBSecrets(secrets)
cfg.ApplyEnvOverrides()
slog.Info("config secrets loaded from DB", "count", len(secrets))
}
}
return pgStores, traceCollector, snapshotWorker
}
// setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill.
// Per-agent DB config takes priority over config file defaults.
func setupMemoryEmbeddings(
cfg *config.Config,
pgStores *store.Stores,
providerRegistry *providers.Registry,
) {
// Wire embedding provider to PGMemoryStore so IndexDocument generates vectors.
// Per-agent DB config takes priority over config file defaults.
if pgStores.Memory != nil {
memCfg := cfg.Agents.Defaults.Memory
if pgStores.Agents != nil {
if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil {
if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil {
memCfg = agentMemCfg
slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey)
}
}
}
if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil {
pgStores.Memory.SetEmbeddingProvider(embProvider)
slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model())
// Backfill embeddings for existing chunks that were stored without vectors.
type backfiller interface {
BackfillEmbeddings(ctx context.Context) (int, error)
}
if bf, ok := pgStores.Memory.(backfiller); ok {
go func() {
bgCtx := context.Background()
count, err := bf.BackfillEmbeddings(bgCtx)
if err != nil {
slog.Warn("memory embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("memory embeddings backfill complete", "chunks_updated", count)
}
}()
}
} else {
slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors")
}
}
}
// loadBootstrapFiles loads bootstrap files for the default agent's system prompt from DB.
// Seeds if empty; falls back to filesystem as last resort.
func loadBootstrapFiles(
pgStores *store.Stores,
workspace string,
agentCfg config.AgentDefaults,
) []bootstrap.ContextFile {
// Load bootstrap files for default agent's system prompt from DB.
// Seeds if empty; falls back to filesystem as last resort.
var contextFiles []bootstrap.ContextFile
if pgStores.Agents != nil {
bgCtx := context.Background()
defaultAgent, agErr := pgStores.Agents.GetByKey(bgCtx, "default")
if agErr == nil {
dbFiles := bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
if len(dbFiles) > 0 {
contextFiles = dbFiles
slog.Info("bootstrap loaded from store", "count", len(dbFiles))
} else {
// DB empty → seed templates, then load
if _, seedErr := bootstrap.SeedToStore(bgCtx, pgStores.Agents, defaultAgent.ID, defaultAgent.AgentType); seedErr != nil {
slog.Warn("failed to seed bootstrap to store", "error", seedErr)
} else {
contextFiles = bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
slog.Info("bootstrap seeded and loaded from store", "count", len(contextFiles))
}
}
}
}
if len(contextFiles) == 0 {
// DB fallback: load from workspace filesystem
rawFiles := bootstrap.LoadWorkspaceFiles(workspace)
truncCfg := bootstrap.TruncateConfig{
MaxCharsPerFile: agentCfg.BootstrapMaxChars,
TotalMaxChars: agentCfg.BootstrapTotalMaxChars,
}
if truncCfg.MaxCharsPerFile <= 0 {
truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile
}
if truncCfg.TotalMaxChars <= 0 {
truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars
}
contextFiles = bootstrap.BuildContextFiles(rawFiles, truncCfg)
slog.Info("bootstrap loaded from filesystem", "count", len(contextFiles))
}
// Debug: log bootstrap file loading results
{
var loadedNames []string
for _, cf := range contextFiles {
loadedNames = append(loadedNames, fmt.Sprintf("%s(%d)", cf.Path, len(cf.Content)))
}
slog.Info("bootstrap context files", "count", len(contextFiles), "files", loadedNames)
}
return contextFiles
}
// setupSkillsSystem creates the skills loader, registers skill tools, wires skills-store,
// seeds bundled skills, and enables embedding-based skill search.
func setupSkillsSystem(
cfg *config.Config,
workspace string,
dataDir string,
pgStores *store.Stores,
toolsReg *tools.Registry,
providerRegistry *providers.Registry,
msgBus *bus.MessageBus,
) (*skills.Loader, *tools.SkillSearchTool, string) {
// Skills loader + search tool
// Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/.
globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR")
if globalSkillsDir == "" {
globalSkillsDir = filepath.Join(dataDir, "skills")
}
// Bundled skills: shipped with the Docker image at /app/bundled-skills/.
// Lowest priority — managed (skills-store) and user-uploaded skills override these.
builtinSkillsDir := os.Getenv("GOCLAW_BUILTIN_SKILLS_DIR")
if builtinSkillsDir == "" {
builtinSkillsDir = "/app/bundled-skills"
}
skillsLoader := skills.NewLoader(workspace, globalSkillsDir, builtinSkillsDir)
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
toolsReg.Register(skillSearchTool)
toolsReg.Register(tools.NewUseSkillTool())
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills()))
// Wire skills-store directory into filesystem loader so agents
// can discover uploaded skills in their system prompt and BM25 search index.
if pgStores.Skills != nil {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
skillsLoader.SetManagedDir(storeDirs[0])
slog.Info("skills-store directory wired into loader", "dir", storeDirs[0])
// Seed system/bundled skills into DB
bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
if bundledSkillsDir == "" {
// Check common locations: Docker default, then local dev
for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
bundledSkillsDir = candidate
break
}
}
}
if bundledSkillsDir != "" {
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
seeder := skills.NewSeeder(bundledSkillsDir, storeDirs[0], pgSkills)
seeded, skipped, seededSkills, err := seeder.Seed(context.Background())
if err != nil {
slog.Warn("system skills seed failed", "error", err)
} else {
if seeded > 0 {
slog.Info("system skills seeded", "seeded", seeded, "skipped", skipped)
}
// Check dependencies asynchronously — does not block startup.
// Emits WS events per-skill so UI updates in realtime.
if len(seededSkills) > 0 {
seeder.CheckDepsAsync(seededSkills, msgBus)
}
}
}
}
}
}
// Publish skill tool — lets agents register created skills in the database
if pgStores.Skills != nil {
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader))
slog.Info("publish_skill tool registered")
}
}
}
// Wire embedding-based skill search + per-agent access filtering
if pgStores.Skills != nil {
if sas, ok := pgStores.Skills.(store.SkillAccessStore); ok {
skillSearchTool.SetSkillAccessStore(sas)
}
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
memCfg := cfg.Agents.Defaults.Memory
if embProvider := resolveEmbeddingProvider(cfg, memCfg, providerRegistry); embProvider != nil {
pgSkills.SetEmbeddingProvider(embProvider)
skillSearchTool.SetEmbeddingSearcher(pgSkills, embProvider)
slog.Info("skill embeddings enabled", "provider", embProvider.Name())
// Backfill embeddings for existing skills
go func() {
count, err := pgSkills.BackfillSkillEmbeddings(context.Background())
if err != nil {
slog.Warn("skill embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("skill embeddings backfill complete", "skills_updated", count)
}
}()
}
}
}
return skillsLoader, skillSearchTool, globalSkillsDir
}
+9 -3
View File
@@ -86,6 +86,8 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
sb.WriteString("- Create all tasks first, then briefly tell the user what you delegated\n")
sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n")
sb.WriteString("- Results arrive automatically — do NOT present partial results\n")
sb.WriteString("- **Prefer delegation** — if the user asks to involve the team, delegate tasks immediately. Do NOT do the work yourself first then hand off to members\n")
sb.WriteString("- **Do NOT block on completed tasks** — if a dependency task is already done, pass its result in the new task's description instead of using blocked_by\n")
sb.WriteString("- For dependency chains: use `blocked_by` to sequence tasks\n")
sb.WriteString("\n## Task Decomposition (CRITICAL)\n\n")
@@ -102,9 +104,13 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
sb.WriteString(" Do NOT use placeholders like \"task_1\" — only real UUIDs work.\n\n")
sb.WriteString("## Orchestration Patterns\n\n")
sb.WriteString("- **Parallel**: Create multiple tasks with different assignees simultaneously\n")
sb.WriteString("- **Sequential**: Create task B with `blocked_by=[task_A_id]`\n")
sb.WriteString("- **Mixed**: A+B parallel → review both → C combines outputs\n\n")
sb.WriteString("For complex requests with multiple steps, plan the full task graph UPFRONT and create all tasks in one turn:\n\n")
sb.WriteString("- **Parallel**: Independent tasks → create all with different assignees\n")
sb.WriteString("- **Sequential**: Create Task A first → get its UUID → create Task B with `blocked_by=[A_id]`\n")
sb.WriteString("- **Mixed**: Create A+B (parallel) → create C with `blocked_by=[A_id, B_id]`\n\n")
sb.WriteString("Create tasks in order: independent tasks first, then dependent tasks using the returned UUIDs.\n")
sb.WriteString("The system auto-dispatches blocked tasks when their dependencies complete.\n")
sb.WriteString("Do NOT wait for results to create follow-up tasks — plan the full pipeline ahead.\n\n")
sb.WriteString("After results: present to user (if done) or continue orchestrating.\n")
sb.WriteString("Vary announcement phrasing between delegation rounds.\n")
+4 -3
View File
@@ -70,10 +70,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
}
}
// Clean up temporary media files after successful (or failed) send.
// Files are created by tools (create_image, tts) and only needed for the send.
// Clean up temp media files only. Workspace-generated files are preserved
// so they remain accessible via workspace/web UI after delivery.
tmpDir := os.TempDir()
for _, media := range msg.Media {
if media.URL != "" {
if media.URL != "" && strings.HasPrefix(media.URL, tmpDir) {
if err := os.Remove(media.URL); err != nil {
slog.Debug("failed to clean up media file", "path", media.URL, "error", err)
}
+4 -579
View File
@@ -1,14 +1,9 @@
package pg
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
@@ -51,6 +46,10 @@ func NewPGSkillStore(db *sql.DB, baseDir string) *PGSkillStore {
}
}
func (s *PGSkillStore) Version() int64 { return s.version.Load() }
func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} }
func (s *PGSkillStore) ListSkills() []store.SkillInfo {
currentVer := s.version.Load()
@@ -162,577 +161,3 @@ func (s *PGSkillStore) StoreMissingDeps(id uuid.UUID, missing []string) error {
}
return err
}
func (s *PGSkillStore) LoadSkill(name string) (string, bool) {
var slug string
var version int
err := s.db.QueryRow(
"SELECT slug, version FROM skills WHERE slug = $1 AND status = 'active'", name,
).Scan(&slug, &version)
if err != nil {
return "", false
}
content, err := readSkillContent(s.baseDir, slug, version)
if err != nil {
return "", false
}
return content, true
}
func (s *PGSkillStore) LoadForContext(allowList []string) string {
skills := s.FilterSkills(allowList)
if len(skills) == 0 {
return ""
}
var parts []string
for _, sk := range skills {
content, ok := s.LoadSkill(sk.Name)
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content))
}
if len(parts) == 0 {
return ""
}
var result strings.Builder
result.WriteString("## Available Skills\n\n")
for i, p := range parts {
if i > 0 {
result.WriteString("\n\n---\n\n")
}
result.WriteString(p)
}
return result.String()
}
func (s *PGSkillStore) BuildSummary(allowList []string) string {
skills := s.FilterSkills(allowList)
if len(skills) == 0 {
return ""
}
var result strings.Builder
result.WriteString("<available_skills>\n")
for _, sk := range skills {
result.WriteString(" <skill>\n")
result.WriteString(fmt.Sprintf(" <name>%s</name>\n", sk.Name))
result.WriteString(fmt.Sprintf(" <description>%s</description>\n", sk.Description))
result.WriteString(fmt.Sprintf(" <location>%s</location>\n", sk.Path))
result.WriteString(" </skill>\n")
}
result.WriteString("</available_skills>")
return result.String()
}
func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
var id uuid.UUID
var skillName, slug, visibility string
var desc *string
var tags []string
var version int
var isSystem bool
err := s.db.QueryRow(
"SELECT id, name, slug, description, visibility, tags, version, is_system FROM skills WHERE slug = $1 AND status = 'active'", name,
).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem)
if err != nil {
return nil, false
}
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir)
info.Visibility = visibility
info.Tags = tags
info.IsSystem = isSystem
return &info, true
}
func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo {
all := s.ListSkills()
var filtered []store.SkillInfo
if allowList == nil {
// No allowList → return all enabled skills (for agent injection)
for _, sk := range all {
if sk.Enabled {
filtered = append(filtered, sk)
}
}
return filtered
}
if len(allowList) == 0 {
return nil
}
allowed := make(map[string]bool, len(allowList))
for _, name := range allowList {
allowed[name] = true
}
for _, sk := range all {
if sk.Enabled && allowed[sk.Slug] {
filtered = append(filtered, sk)
}
}
return filtered
}
func (s *PGSkillStore) Version() int64 { return s.version.Load() }
func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} }
// --- CRUD for managed skill upload ---
func (s *PGSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error {
id := store.GenNewID()
_, err := s.db.Exec(
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, NOW(), NOW())`,
id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash,
)
if err == nil {
s.BumpVersion()
}
return err
}
func (s *PGSkillStore) UpdateSkill(id uuid.UUID, updates map[string]any) error {
if err := execMapUpdate(context.Background(), s.db, "skills", id, updates); err != nil {
return err
}
s.BumpVersion()
return nil
}
func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error {
// Reject deletion of system skills
var isSystem bool
if err := s.db.QueryRow("SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil {
return fmt.Errorf("check skill: %w", err)
}
if isSystem {
return fmt.Errorf("cannot delete system skill")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Cascade: remove all agent grants for this skill
if _, err := tx.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill grants: %w", err)
}
// Cascade: remove all user grants for this skill
if _, err := tx.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill user grants: %w", err)
}
// Soft-delete the skill itself
if _, err := tx.Exec("UPDATE skills SET status = 'archived' WHERE id = $1", id); err != nil {
return fmt.Errorf("archive skill: %w", err)
}
if err := tx.Commit(); err != nil {
return err
}
s.BumpVersion()
return nil
}
// SkillCreateParams holds parameters for creating a managed skill.
type SkillCreateParams struct {
Name string
Slug string
Description *string
OwnerID string
Visibility string
Status string // "active" or "archived" (defaults to "active" if empty)
Version int
FilePath string
FileSize int64
FileHash *string
Frontmatter map[string]string // parsed YAML frontmatter from SKILL.md
}
// CreateSkillManaged creates a skill from upload parameters.
func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreateParams) (uuid.UUID, error) {
if err := store.ValidateUserID(p.OwnerID); err != nil {
return uuid.Nil, err
}
id := store.GenNewID()
// Marshal frontmatter to JSON for DB storage
fmJSON := []byte("{}")
if len(p.Frontmatter) > 0 {
if b, err := json.Marshal(p.Frontmatter); err == nil {
fmJSON = b
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name, description = EXCLUDED.description,
version = EXCLUDED.version, frontmatter = EXCLUDED.frontmatter,
file_path = EXCLUDED.file_path,
file_size = EXCLUDED.file_size, file_hash = EXCLUDED.file_hash,
visibility = CASE WHEN skills.status = 'archived' THEN 'private' ELSE skills.visibility END,
status = 'active', updated_at = NOW()`,
id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, p.Version,
fmJSON, p.FilePath, p.FileSize, p.FileHash,
)
if err == nil {
s.BumpVersion()
// Generate embedding asynchronously
desc := ""
if p.Description != nil {
desc = *p.Description
}
go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc)
}
return id, err
}
// GetSkillFilePath returns the filesystem path and version for a skill by UUID.
func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) {
err := s.db.QueryRow(
"SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id,
).Scan(&filePath, &slug, &version)
return filePath, slug, version, err == nil
}
// GetNextVersion returns the next version number for a skill slug.
func (s *PGSkillStore) GetNextVersion(slug string) int {
var maxVersion int
s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = $1", slug).Scan(&maxVersion)
return maxVersion + 1
}
// UpsertSystemSkill creates or updates a system skill.
// Returns (id, changed, actualFilePath, error).
// When hash is unchanged, returns the existing file_path from DB so the caller
// uses the correct directory for dep scanning (not a non-existent next-version dir).
func (s *PGSkillStore) UpsertSystemSkill(ctx context.Context, p SkillCreateParams) (uuid.UUID, bool, string, error) {
// Check if skill already exists
var existingID uuid.UUID
var existingHash *string
var existingFilePath string
err := s.db.QueryRowContext(ctx,
"SELECT id, file_hash, file_path FROM skills WHERE slug = $1", p.Slug,
).Scan(&existingID, &existingHash, &existingFilePath)
if err == nil {
// Skill exists — check if hash changed
if existingHash != nil && p.FileHash != nil && *existingHash == *p.FileHash {
return existingID, false, existingFilePath, nil // unchanged, use existing path
}
// existingHash is nil (old record without hash) — backfill hash without bumping version
if existingHash == nil && p.FileHash != nil {
_, _ = s.db.ExecContext(ctx,
`UPDATE skills SET file_hash = $1, updated_at = NOW() WHERE id = $2`,
p.FileHash, existingID,
)
return existingID, false, existingFilePath, nil
}
// Hash genuinely changed — full update with new version
fmJSON := marshalFrontmatter(p.Frontmatter)
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET name = $1, description = $2, version = $3, frontmatter = $4,
file_path = $5, file_size = $6, file_hash = $7, is_system = true,
visibility = 'public', status = $8, updated_at = NOW()
WHERE id = $9`,
p.Name, p.Description, p.Version, fmJSON,
p.FilePath, p.FileSize, p.FileHash, p.Status, existingID,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("update system skill: %w", err)
}
s.BumpVersion()
return existingID, true, p.FilePath, nil
}
// New skill — insert
id := store.GenNewID()
fmJSON := marshalFrontmatter(p.Frontmatter)
_, err = s.db.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status,
is_system, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'system', 'public', $5, $6, true, $7, $8, $9, $10, NOW(), NOW())`,
id, p.Name, p.Slug, p.Description, p.Version, p.Status,
fmJSON, p.FilePath, p.FileSize, p.FileHash,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("insert system skill: %w", err)
}
s.BumpVersion()
// Generate embedding asynchronously
desc := ""
if p.Description != nil {
desc = *p.Description
}
go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc)
return id, true, p.FilePath, nil
}
// ListSystemSkillDirs returns slug->file_path map for all enabled system skills.
// Disabled system skills are excluded — dep checking and injection are skipped for them.
func (s *PGSkillStore) ListSystemSkillDirs() map[string]string {
rows, err := s.db.Query(
`SELECT slug, file_path FROM skills WHERE is_system = true AND enabled = true`)
if err != nil {
return nil
}
defer rows.Close()
dirs := make(map[string]string)
for rows.Next() {
var slug, path string
if err := rows.Scan(&slug, &path); err != nil {
continue
}
dirs[slug] = path
}
return dirs
}
// IsSystemSkill checks if a skill slug belongs to a system skill.
func (s *PGSkillStore) IsSystemSkill(slug string) bool {
var isSystem bool
err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = $1", slug).Scan(&isSystem)
return err == nil && isSystem
}
// GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag.
// Used by admin operations (e.g. toggle) that need full skill info.
func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) {
var name, slug, visibility, status string
var desc *string
var tags []string
var version int
var isSystem, enabled bool
var depsRaw []byte
err := s.db.QueryRow(
`SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps
FROM skills WHERE id = $1`,
id,
).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw)
if err != nil {
return store.SkillInfo{}, false
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir)
info.Visibility = visibility
info.Tags = tags
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
return info, true
}
// ToggleSkill enables or disables a skill by UUID.
func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error {
_, err := s.db.Exec(
`UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`,
enabled, id,
)
if err == nil {
s.BumpVersion()
}
return err
}
// parseDepsColumn extracts the missing deps list from the deps JSONB column.
func parseDepsColumn(raw []byte) []string {
if len(raw) == 0 {
return nil
}
var d struct {
Missing []string `json:"missing"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil
}
if len(d.Missing) == 0 {
return nil
}
return d.Missing
}
func parseFrontmatterAuthor(raw []byte) string {
if len(raw) == 0 {
return ""
}
var fm map[string]string
if err := json.Unmarshal(raw, &fm); err != nil {
return ""
}
return fm["author"]
}
func marshalFrontmatter(fm map[string]string) []byte {
if len(fm) == 0 {
return []byte("{}")
}
b, err := json.Marshal(fm)
if err != nil {
return []byte("{}")
}
return b
}
// --- Embedding skill search (store.EmbeddingSkillSearcher) ---
// SetEmbeddingProvider sets the embedding provider for vector-based skill search.
func (s *PGSkillStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
s.embProvider = provider
}
// SearchByEmbedding performs vector similarity search over skills using pgvector cosine distance.
func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float32, limit int) ([]store.SkillSearchResult, error) {
if limit <= 0 {
limit = 5
}
vecStr := vectorToString(embedding)
rows, err := s.db.QueryContext(ctx,
`SELECT name, slug, COALESCE(description, ''), version,
1 - (embedding <=> $1::vector) AS score
FROM skills
WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL
AND visibility != 'private'
ORDER BY embedding <=> $2::vector
LIMIT $3`,
vecStr, vecStr, limit,
)
if err != nil {
return nil, fmt.Errorf("embedding skill search: %w", err)
}
defer rows.Close()
var results []store.SkillSearchResult
for rows.Next() {
var r store.SkillSearchResult
var version int
if err := rows.Scan(&r.Name, &r.Slug, &r.Description, &version, &r.Score); err != nil {
continue
}
r.Path = fmt.Sprintf("%s/%s/%d/SKILL.md", s.baseDir, r.Slug, version)
results = append(results, r)
}
return results, nil
}
// BackfillSkillEmbeddings generates embeddings for all active skills that don't have one yet.
func (s *PGSkillStore) BackfillSkillEmbeddings(ctx context.Context) (int, error) {
if s.embProvider == nil {
return 0, nil
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, COALESCE(description, '') FROM skills WHERE status = 'active' AND enabled = true AND embedding IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type skillRow struct {
id uuid.UUID
name string
desc string
}
var pending []skillRow
for rows.Next() {
var r skillRow
if err := rows.Scan(&r.id, &r.name, &r.desc); err != nil {
continue
}
pending = append(pending, r)
}
if len(pending) == 0 {
return 0, nil
}
slog.Info("backfilling skill embeddings", "count", len(pending))
updated := 0
for _, sk := range pending {
text := sk.name
if sk.desc != "" {
text += ": " + sk.desc
}
embeddings, err := s.embProvider.Embed(ctx, []string{text})
if err != nil {
slog.Warn("skill embedding failed", "skill", sk.name, "error", err)
continue
}
if len(embeddings) == 0 || len(embeddings[0]) == 0 {
continue
}
vecStr := vectorToString(embeddings[0])
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET embedding = $1::vector WHERE id = $2`, vecStr, sk.id)
if err != nil {
slog.Warn("skill embedding update failed", "skill", sk.name, "error", err)
continue
}
updated++
}
slog.Info("skill embeddings backfill complete", "updated", updated)
return updated, nil
}
// generateEmbedding creates an embedding for a skill's name+description and stores it.
func (s *PGSkillStore) generateEmbedding(ctx context.Context, slug, name, description string) {
if s.embProvider == nil {
return
}
text := name
if description != "" {
text += ": " + description
}
embeddings, err := s.embProvider.Embed(ctx, []string{text})
if err != nil {
slog.Warn("skill embedding generation failed", "skill", name, "error", err)
return
}
if len(embeddings) == 0 || len(embeddings[0]) == 0 {
return
}
vecStr := vectorToString(embeddings[0])
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET embedding = $1::vector WHERE slug = $2 AND status = 'active'`, vecStr, slug)
if err != nil {
slog.Warn("skill embedding store failed", "skill", name, "error", err)
}
}
// --- Helpers ---
func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string) store.SkillInfo {
d := ""
if desc != nil {
d = *desc
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version),
BaseDir: fmt.Sprintf("%s/%s/%d", baseDir, slug, version),
Source: "managed",
Description: d,
Version: version,
}
}
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
func readSkillContent(baseDir, slug string, version int) (string, error) {
path := fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
// Normalize line endings (Windows CRLF → LF) and strip frontmatter
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = skillFrontmatterRe.ReplaceAllString(content, "")
return content, nil
}
+103
View File
@@ -0,0 +1,103 @@
package pg
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// UpsertSystemSkill creates or updates a system skill.
// Returns (id, changed, actualFilePath, error).
// When hash is unchanged, returns the existing file_path from DB so the caller
// uses the correct directory for dep scanning (not a non-existent next-version dir).
func (s *PGSkillStore) UpsertSystemSkill(ctx context.Context, p SkillCreateParams) (uuid.UUID, bool, string, error) {
// Check if skill already exists
var existingID uuid.UUID
var existingHash *string
var existingFilePath string
err := s.db.QueryRowContext(ctx,
"SELECT id, file_hash, file_path FROM skills WHERE slug = $1", p.Slug,
).Scan(&existingID, &existingHash, &existingFilePath)
if err == nil {
// Skill exists — check if hash changed
if existingHash != nil && p.FileHash != nil && *existingHash == *p.FileHash {
return existingID, false, existingFilePath, nil // unchanged, use existing path
}
// existingHash is nil (old record without hash) — backfill hash without bumping version
if existingHash == nil && p.FileHash != nil {
_, _ = s.db.ExecContext(ctx,
`UPDATE skills SET file_hash = $1, updated_at = NOW() WHERE id = $2`,
p.FileHash, existingID,
)
return existingID, false, existingFilePath, nil
}
// Hash genuinely changed — full update with new version
fmJSON := marshalFrontmatter(p.Frontmatter)
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET name = $1, description = $2, version = $3, frontmatter = $4,
file_path = $5, file_size = $6, file_hash = $7, is_system = true,
visibility = 'public', status = $8, updated_at = NOW()
WHERE id = $9`,
p.Name, p.Description, p.Version, fmJSON,
p.FilePath, p.FileSize, p.FileHash, p.Status, existingID,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("update system skill: %w", err)
}
s.BumpVersion()
return existingID, true, p.FilePath, nil
}
// New skill — insert
id := store.GenNewID()
fmJSON := marshalFrontmatter(p.Frontmatter)
_, err = s.db.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status,
is_system, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'system', 'public', $5, $6, true, $7, $8, $9, $10, NOW(), NOW())`,
id, p.Name, p.Slug, p.Description, p.Version, p.Status,
fmJSON, p.FilePath, p.FileSize, p.FileHash,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("insert system skill: %w", err)
}
s.BumpVersion()
// Generate embedding asynchronously
desc := ""
if p.Description != nil {
desc = *p.Description
}
go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc)
return id, true, p.FilePath, nil
}
// ListSystemSkillDirs returns slug->file_path map for all enabled system skills.
// Disabled system skills are excluded — dep checking and injection are skipped for them.
func (s *PGSkillStore) ListSystemSkillDirs() map[string]string {
rows, err := s.db.Query(
`SELECT slug, file_path FROM skills WHERE is_system = true AND enabled = true`)
if err != nil {
return nil
}
defer rows.Close()
dirs := make(map[string]string)
for rows.Next() {
var slug, path string
if err := rows.Scan(&slug, &path); err != nil {
continue
}
dirs[slug] = path
}
return dirs
}
// IsSystemSkill checks if a skill slug belongs to a system skill.
func (s *PGSkillStore) IsSystemSkill(slug string) bool {
var isSystem bool
err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = $1", slug).Scan(&isSystem)
return err == nil && isSystem
}
+181
View File
@@ -0,0 +1,181 @@
package pg
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
func (s *PGSkillStore) LoadSkill(name string) (string, bool) {
var slug string
var version int
err := s.db.QueryRow(
"SELECT slug, version FROM skills WHERE slug = $1 AND status = 'active'", name,
).Scan(&slug, &version)
if err != nil {
return "", false
}
content, err := readSkillContent(s.baseDir, slug, version)
if err != nil {
return "", false
}
return content, true
}
func (s *PGSkillStore) LoadForContext(allowList []string) string {
skills := s.FilterSkills(allowList)
if len(skills) == 0 {
return ""
}
var parts []string
for _, sk := range skills {
content, ok := s.LoadSkill(sk.Name)
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content))
}
if len(parts) == 0 {
return ""
}
var result strings.Builder
result.WriteString("## Available Skills\n\n")
for i, p := range parts {
if i > 0 {
result.WriteString("\n\n---\n\n")
}
result.WriteString(p)
}
return result.String()
}
func (s *PGSkillStore) BuildSummary(allowList []string) string {
skills := s.FilterSkills(allowList)
if len(skills) == 0 {
return ""
}
var result strings.Builder
result.WriteString("<available_skills>\n")
for _, sk := range skills {
result.WriteString(" <skill>\n")
result.WriteString(fmt.Sprintf(" <name>%s</name>\n", sk.Name))
result.WriteString(fmt.Sprintf(" <description>%s</description>\n", sk.Description))
result.WriteString(fmt.Sprintf(" <location>%s</location>\n", sk.Path))
result.WriteString(" </skill>\n")
}
result.WriteString("</available_skills>")
return result.String()
}
func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
var id uuid.UUID
var skillName, slug, visibility string
var desc *string
var tags []string
var version int
var isSystem bool
err := s.db.QueryRow(
"SELECT id, name, slug, description, visibility, tags, version, is_system FROM skills WHERE slug = $1 AND status = 'active'", name,
).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem)
if err != nil {
return nil, false
}
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir)
info.Visibility = visibility
info.Tags = tags
info.IsSystem = isSystem
return &info, true
}
func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo {
all := s.ListSkills()
var filtered []store.SkillInfo
if allowList == nil {
// No allowList → return all enabled skills (for agent injection)
for _, sk := range all {
if sk.Enabled {
filtered = append(filtered, sk)
}
}
return filtered
}
if len(allowList) == 0 {
return nil
}
allowed := make(map[string]bool, len(allowList))
for _, name := range allowList {
allowed[name] = true
}
for _, sk := range all {
if sk.Enabled && allowed[sk.Slug] {
filtered = append(filtered, sk)
}
}
return filtered
}
// GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag.
// Used by admin operations (e.g. toggle) that need full skill info.
func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) {
var name, slug, visibility, status string
var desc *string
var tags []string
var version int
var isSystem, enabled bool
var depsRaw []byte
err := s.db.QueryRow(
`SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps
FROM skills WHERE id = $1`,
id,
).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw)
if err != nil {
return store.SkillInfo{}, false
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir)
info.Visibility = visibility
info.Tags = tags
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
return info, true
}
func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string) store.SkillInfo {
d := ""
if desc != nil {
d = *desc
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version),
BaseDir: fmt.Sprintf("%s/%s/%d", baseDir, slug, version),
Source: "managed",
Description: d,
Version: version,
}
}
func readSkillContent(baseDir, slug string, version int) (string, error) {
path := fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
// Normalize line endings (Windows CRLF → LF) and strip frontmatter
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = skillFrontmatterRe.ReplaceAllString(content, "")
return content, nil
}
+189
View File
@@ -0,0 +1,189 @@
package pg
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SkillCreateParams holds parameters for creating a managed skill.
type SkillCreateParams struct {
Name string
Slug string
Description *string
OwnerID string
Visibility string
Status string // "active" or "archived" (defaults to "active" if empty)
Version int
FilePath string
FileSize int64
FileHash *string
Frontmatter map[string]string // parsed YAML frontmatter from SKILL.md
}
func (s *PGSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error {
id := store.GenNewID()
_, err := s.db.Exec(
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, NOW(), NOW())`,
id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash,
)
if err == nil {
s.BumpVersion()
}
return err
}
func (s *PGSkillStore) UpdateSkill(id uuid.UUID, updates map[string]any) error {
if err := execMapUpdate(context.Background(), s.db, "skills", id, updates); err != nil {
return err
}
s.BumpVersion()
return nil
}
func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error {
// Reject deletion of system skills
var isSystem bool
if err := s.db.QueryRow("SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil {
return fmt.Errorf("check skill: %w", err)
}
if isSystem {
return fmt.Errorf("cannot delete system skill")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Cascade: remove all agent grants for this skill
if _, err := tx.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill grants: %w", err)
}
// Cascade: remove all user grants for this skill
if _, err := tx.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill user grants: %w", err)
}
// Soft-delete the skill itself
if _, err := tx.Exec("UPDATE skills SET status = 'archived' WHERE id = $1", id); err != nil {
return fmt.Errorf("archive skill: %w", err)
}
if err := tx.Commit(); err != nil {
return err
}
s.BumpVersion()
return nil
}
// CreateSkillManaged creates a skill from upload parameters.
func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreateParams) (uuid.UUID, error) {
if err := store.ValidateUserID(p.OwnerID); err != nil {
return uuid.Nil, err
}
id := store.GenNewID()
// Marshal frontmatter to JSON for DB storage
fmJSON := []byte("{}")
if len(p.Frontmatter) > 0 {
if b, err := json.Marshal(p.Frontmatter); err == nil {
fmJSON = b
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name, description = EXCLUDED.description,
version = EXCLUDED.version, frontmatter = EXCLUDED.frontmatter,
file_path = EXCLUDED.file_path,
file_size = EXCLUDED.file_size, file_hash = EXCLUDED.file_hash,
visibility = CASE WHEN skills.status = 'archived' THEN 'private' ELSE skills.visibility END,
status = 'active', updated_at = NOW()`,
id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, p.Version,
fmJSON, p.FilePath, p.FileSize, p.FileHash,
)
if err == nil {
s.BumpVersion()
// Generate embedding asynchronously
desc := ""
if p.Description != nil {
desc = *p.Description
}
go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc)
}
return id, err
}
// GetSkillFilePath returns the filesystem path and version for a skill by UUID.
func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) {
err := s.db.QueryRow(
"SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id,
).Scan(&filePath, &slug, &version)
return filePath, slug, version, err == nil
}
// GetNextVersion returns the next version number for a skill slug.
func (s *PGSkillStore) GetNextVersion(slug string) int {
var maxVersion int
s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = $1", slug).Scan(&maxVersion)
return maxVersion + 1
}
// ToggleSkill enables or disables a skill by UUID.
func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error {
_, err := s.db.Exec(
`UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`,
enabled, id,
)
if err == nil {
s.BumpVersion()
}
return err
}
// parseDepsColumn extracts the missing deps list from the deps JSONB column.
func parseDepsColumn(raw []byte) []string {
if len(raw) == 0 {
return nil
}
var d struct {
Missing []string `json:"missing"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil
}
if len(d.Missing) == 0 {
return nil
}
return d.Missing
}
func parseFrontmatterAuthor(raw []byte) string {
if len(raw) == 0 {
return ""
}
var fm map[string]string
if err := json.Unmarshal(raw, &fm); err != nil {
return ""
}
return fm["author"]
}
func marshalFrontmatter(fm map[string]string) []byte {
if len(fm) == 0 {
return []byte("{}")
}
b, err := json.Marshal(fm)
if err != nil {
return []byte("{}")
}
return b
}
+136
View File
@@ -0,0 +1,136 @@
package pg
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SetEmbeddingProvider sets the embedding provider for vector-based skill search.
func (s *PGSkillStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
s.embProvider = provider
}
// SearchByEmbedding performs vector similarity search over skills using pgvector cosine distance.
func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float32, limit int) ([]store.SkillSearchResult, error) {
if limit <= 0 {
limit = 5
}
vecStr := vectorToString(embedding)
rows, err := s.db.QueryContext(ctx,
`SELECT name, slug, COALESCE(description, ''), version,
1 - (embedding <=> $1::vector) AS score
FROM skills
WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL
AND visibility != 'private'
ORDER BY embedding <=> $2::vector
LIMIT $3`,
vecStr, vecStr, limit,
)
if err != nil {
return nil, fmt.Errorf("embedding skill search: %w", err)
}
defer rows.Close()
var results []store.SkillSearchResult
for rows.Next() {
var r store.SkillSearchResult
var version int
if err := rows.Scan(&r.Name, &r.Slug, &r.Description, &version, &r.Score); err != nil {
continue
}
r.Path = fmt.Sprintf("%s/%s/%d/SKILL.md", s.baseDir, r.Slug, version)
results = append(results, r)
}
return results, nil
}
// BackfillSkillEmbeddings generates embeddings for all active skills that don't have one yet.
func (s *PGSkillStore) BackfillSkillEmbeddings(ctx context.Context) (int, error) {
if s.embProvider == nil {
return 0, nil
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, COALESCE(description, '') FROM skills WHERE status = 'active' AND enabled = true AND embedding IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type skillRow struct {
id uuid.UUID
name string
desc string
}
var pending []skillRow
for rows.Next() {
var r skillRow
if err := rows.Scan(&r.id, &r.name, &r.desc); err != nil {
continue
}
pending = append(pending, r)
}
if len(pending) == 0 {
return 0, nil
}
slog.Info("backfilling skill embeddings", "count", len(pending))
updated := 0
for _, sk := range pending {
text := sk.name
if sk.desc != "" {
text += ": " + sk.desc
}
embeddings, err := s.embProvider.Embed(ctx, []string{text})
if err != nil {
slog.Warn("skill embedding failed", "skill", sk.name, "error", err)
continue
}
if len(embeddings) == 0 || len(embeddings[0]) == 0 {
continue
}
vecStr := vectorToString(embeddings[0])
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET embedding = $1::vector WHERE id = $2`, vecStr, sk.id)
if err != nil {
slog.Warn("skill embedding update failed", "skill", sk.name, "error", err)
continue
}
updated++
}
slog.Info("skill embeddings backfill complete", "updated", updated)
return updated, nil
}
// generateEmbedding creates an embedding for a skill's name+description and stores it.
func (s *PGSkillStore) generateEmbedding(ctx context.Context, slug, name, description string) {
if s.embProvider == nil {
return
}
text := name
if description != "" {
text += ": " + description
}
embeddings, err := s.embProvider.Embed(ctx, []string{text})
if err != nil {
slog.Warn("skill embedding generation failed", "skill", name, "error", err)
return
}
if len(embeddings) == 0 || len(embeddings[0]) == 0 {
return
}
vecStr := vectorToString(embeddings[0])
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET embedding = $1::vector WHERE slug = $2 AND status = 'active'`, vecStr, slug)
if err != nil {
slog.Warn("skill embedding store failed", "skill", name, "error", err)
}
}
+15 -723
View File
@@ -217,6 +217,21 @@ func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.Tea
return &tasks[0], nil
}
func (s *PGTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.id = ANY($1)`, pq.Array(ids))
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]store.TeamTaskData, error) {
if limit <= 0 {
limit = 20
@@ -234,180 +249,6 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
taskID, store.TeamTaskStatusPending, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for claiming (already claimed or not pending)")
}
return nil
}
func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
WHERE id = $5 AND team_id = $6 AND status = $7`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
taskID, teamID, store.TeamTaskStatusPending,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for assignment (not pending or wrong team)")
}
return nil
}
func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusCompleted, result, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusCompleted, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not found, already completed/cancelled, or wrong team")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
// FailPendingTask marks a pending or blocked task as failed (post-turn validation).
func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, updated_at = $3
WHERE id = $4 AND status IN ($5, $6) AND team_id = $7`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not pending/blocked or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
var tasks []store.TeamTaskData
for rows.Next() {
@@ -468,552 +309,3 @@ func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
}
return tasks, rows.Err()
}
// unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending
// when all blockers are resolved. Must be called within a transaction.
func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
_, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET
blocked_by = array_remove(blocked_by, $1),
status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END,
updated_at = $2
WHERE $1 = ANY(blocked_by)`,
taskID, time.Now(),
)
return err
}
// ============================================================
// Review workflow
// ============================================================
func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, updated_at = $2
WHERE id = $3 AND status = $4 AND team_id = $5`,
store.TeamTaskStatusInReview, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE id = $3 AND status = $4 AND team_id = $5`,
store.TeamTaskStatusCompleted, now,
taskID, store.TeamTaskStatusInReview, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusInReview, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
// ============================================================
// Task comments
// ============================================================
func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
if comment.ID == uuid.Nil {
comment.ID = store.GenNewID()
}
comment.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
comment.ID, comment.TaskID, comment.AgentID,
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
comment.Content, comment.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
WHERE c.task_id = $1
ORDER BY c.created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []store.TeamTaskCommentData
for rows.Next() {
var c store.TeamTaskCommentData
var agentID *uuid.UUID
var userID sql.NullString
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
return nil, err
}
c.AgentID = agentID
if userID.Valid {
c.UserID = userID.String
}
comments = append(comments, c)
}
return comments, rows.Err()
}
// ============================================================
// Audit events
// ============================================================
func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
if event.ID == uuid.Nil {
event.ID = store.GenNewID()
}
event.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
FROM team_task_events
WHERE task_id = $1
ORDER BY created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var events []store.TeamTaskEventData
for rows.Next() {
var e store.TeamTaskEventData
var data json.RawMessage
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &e.CreatedAt); err != nil {
return nil, err
}
e.Data = data
events = append(events, e)
}
return events, rows.Err()
}
func (s *PGTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
FROM team_task_events e
JOIN team_tasks t ON t.id = e.task_id
WHERE t.team_id = $1
ORDER BY e.created_at DESC
LIMIT $2 OFFSET $3`,
teamID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []store.TeamTaskEventData
for rows.Next() {
var e store.TeamTaskEventData
var data json.RawMessage
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &e.CreatedAt); err != nil {
return nil, err
}
e.Data = data
events = append(events, e)
}
return events, rows.Err()
}
// ============================================================
// Attachments
// ============================================================
func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
if att.ID == uuid.Nil {
att.ID = store.GenNewID()
}
att.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_attachments (id, task_id, file_id, added_by, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (task_id, file_id) DO NOTHING`,
att.ID, att.TaskID, att.FileID, att.AddedBy, att.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT a.id, a.task_id, a.file_id, a.added_by, a.created_at,
COALESCE(f.file_name, '') AS file_name
FROM team_task_attachments a
LEFT JOIN team_workspace_files f ON f.id = a.file_id
WHERE a.task_id = $1
ORDER BY a.created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var atts []store.TeamTaskAttachmentData
for rows.Next() {
var a store.TeamTaskAttachmentData
var addedBy *uuid.UUID
if err := rows.Scan(&a.ID, &a.TaskID, &a.FileID, &addedBy, &a.CreatedAt, &a.FileName); err != nil {
return nil, err
}
a.AddedBy = addedBy
atts = append(atts, a)
}
return atts, rows.Err()
}
func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID, fileID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM team_task_attachments WHERE task_id = $1 AND file_id = $2`,
taskID, fileID,
)
return err
}
// ============================================================
// Progress
// ============================================================
func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
if percent < 0 || percent > 100 {
return fmt.Errorf("progress percent must be 0-100, got %d", percent)
}
// Also renews lock_expires_at as a heartbeat.
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4
WHERE id = $5 AND status = $6 AND team_id = $7`,
percent, step, lockExpires, now,
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// RenewTaskLock extends the lock expiration for an in-progress task.
// Called periodically by the consumer as a heartbeat to prevent
// the ticker from recovering a long-running task.
func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET lock_expires_at = $1, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status = $5`,
lockExpires, now,
taskID, teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// ============================================================
// Stale recovery
// ============================================================
func (s *PGTeamStore) RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE team_id = $3 AND status = $4 AND lock_expires_at IS NOT NULL AND lock_expires_at < $2`,
store.TeamTaskStatusPending, now,
teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE team_id = $3 AND status = $4`,
store.TeamTaskStatusPending, now,
teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1
AND (
t.status = $2
OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4)
)
ORDER BY t.priority DESC, t.created_at
LIMIT $5`,
teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) MarkStaleTasks(ctx context.Context, teamID uuid.UUID, olderThan time.Time) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, updated_at = $2
WHERE team_id = $3 AND status = $4 AND updated_at < $5`,
store.TeamTaskStatusStale, now,
teamID, store.TeamTaskStatusPending, olderThan,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`,
store.TeamTaskStatusPending, now,
taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for reset (not stale/failed or wrong team)")
}
return nil
}
// ============================================================
// Follow-up reminders
// ============================================================
func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6
WHERE id = $7 AND team_id = $8 AND status = $9`,
followupAt, max, message, channel, chatID, now,
taskID, teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1
WHERE id = $2`,
time.Now(), taskID,
)
return err
}
func (s *PGTeamStore) ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1
AND t.followup_at IS NOT NULL
AND t.followup_at <= $2
AND t.status = $3
AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
ORDER BY t.followup_at
LIMIT 50`,
teamID, now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2
WHERE id = $3`,
nextAt, time.Now(), taskID,
)
return err
}
func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW()
WHERE followup_channel = $1 AND followup_chat_id = $2
AND followup_at IS NOT NULL AND status = $3`,
channel, chatID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = $4, followup_max = $5, followup_message = $6,
followup_channel = $2, followup_chat_id = $3, updated_at = NOW()
WHERE team_id = $1
AND status = $7
AND followup_at IS NULL
AND (
(COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3)
OR followup_channel = $2
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
)`,
teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(
SELECT 1 FROM team_tasks
WHERE team_id = $1
AND owner_agent_id IS NOT NULL
AND owner_agent_id != $2
AND status IN ($3, $4, $5)
)`,
teamID, excludeAgentID,
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked,
).Scan(&exists)
return exists, err
}
+184
View File
@@ -0,0 +1,184 @@
package pg
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// Task comments
// ============================================================
func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
if comment.ID == uuid.Nil {
comment.ID = store.GenNewID()
}
comment.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
comment.ID, comment.TaskID, comment.AgentID,
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
comment.Content, comment.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
WHERE c.task_id = $1
ORDER BY c.created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []store.TeamTaskCommentData
for rows.Next() {
var c store.TeamTaskCommentData
var agentID *uuid.UUID
var userID sql.NullString
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
return nil, err
}
c.AgentID = agentID
if userID.Valid {
c.UserID = userID.String
}
comments = append(comments, c)
}
return comments, rows.Err()
}
// ============================================================
// Audit events
// ============================================================
func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
if event.ID == uuid.Nil {
event.ID = store.GenNewID()
}
event.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
FROM team_task_events
WHERE task_id = $1
ORDER BY created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var events []store.TeamTaskEventData
for rows.Next() {
var e store.TeamTaskEventData
var data json.RawMessage
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &e.CreatedAt); err != nil {
return nil, err
}
e.Data = data
events = append(events, e)
}
return events, rows.Err()
}
func (s *PGTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
FROM team_task_events e
JOIN team_tasks t ON t.id = e.task_id
WHERE t.team_id = $1
ORDER BY e.created_at DESC
LIMIT $2 OFFSET $3`,
teamID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []store.TeamTaskEventData
for rows.Next() {
var e store.TeamTaskEventData
var data json.RawMessage
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &e.CreatedAt); err != nil {
return nil, err
}
e.Data = data
events = append(events, e)
}
return events, rows.Err()
}
// ============================================================
// Attachments
// ============================================================
func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
if att.ID == uuid.Nil {
att.ID = store.GenNewID()
}
att.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_attachments (id, task_id, file_id, added_by, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (task_id, file_id) DO NOTHING`,
att.ID, att.TaskID, att.FileID, att.AddedBy, att.CreatedAt,
)
return err
}
func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT a.id, a.task_id, a.file_id, a.added_by, a.created_at,
COALESCE(f.file_name, '') AS file_name
FROM team_task_attachments a
LEFT JOIN team_workspace_files f ON f.id = a.file_id
WHERE a.task_id = $1
ORDER BY a.created_at ASC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var atts []store.TeamTaskAttachmentData
for rows.Next() {
var a store.TeamTaskAttachmentData
var addedBy *uuid.UUID
if err := rows.Scan(&a.ID, &a.TaskID, &a.FileID, &addedBy, &a.CreatedAt, &a.FileName); err != nil {
return nil, err
}
a.AddedBy = addedBy
atts = append(atts, a)
}
return atts, rows.Err()
}
func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID, fileID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM team_task_attachments WHERE task_id = $1 AND file_id = $2`,
taskID, fileID,
)
return err
}
+129
View File
@@ -0,0 +1,129 @@
package pg
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// Follow-up reminders
// ============================================================
func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6
WHERE id = $7 AND team_id = $8 AND status = $9`,
followupAt, max, message, channel, chatID, now,
taskID, teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1
WHERE id = $2`,
time.Now(), taskID,
)
return err
}
func (s *PGTeamStore) ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1
AND t.followup_at IS NOT NULL
AND t.followup_at <= $2
AND t.status = $3
AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
ORDER BY t.followup_at
LIMIT 50`,
teamID, now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2
WHERE id = $3`,
nextAt, time.Now(), taskID,
)
return err
}
func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW()
WHERE followup_channel = $1 AND followup_chat_id = $2
AND followup_at IS NOT NULL AND status = $3`,
channel, chatID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = $4, followup_max = $5, followup_message = $6,
followup_channel = $2, followup_chat_id = $3, updated_at = NOW()
WHERE team_id = $1
AND status = $7
AND followup_at IS NULL
AND (
(COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3)
OR followup_channel = $2
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
)`,
teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(
SELECT 1 FROM team_tasks
WHERE team_id = $1
AND owner_agent_id IS NOT NULL
AND owner_agent_id != $2
AND status IN ($3, $4, $5)
)`,
teamID, excludeAgentID,
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked,
).Scan(&exists)
return exists, err
}
+286
View File
@@ -0,0 +1,286 @@
package pg
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
taskID, store.TeamTaskStatusPending, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for claiming (already claimed or not pending)")
}
return nil
}
func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
WHERE id = $5 AND team_id = $6 AND status = $7`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
taskID, teamID, store.TeamTaskStatusPending,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for assignment (not pending or wrong team)")
}
return nil
}
func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusCompleted, result, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusCompleted, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not found, already completed/cancelled, or wrong team")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
// FailPendingTask marks a pending or blocked task as failed (post-turn validation).
func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, updated_at = $3
WHERE id = $4 AND status IN ($5, $6) AND team_id = $7`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not pending/blocked or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
// unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending
// when all blockers are resolved. Must be called within a transaction.
func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
_, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET
blocked_by = array_remove(blocked_by, $1),
status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END,
updated_at = $2
WHERE $1 = ANY(blocked_by)`,
taskID, time.Now(),
)
return err
}
func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, updated_at = $2
WHERE id = $3 AND status = $4 AND team_id = $5`,
store.TeamTaskStatusInReview, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE id = $3 AND status = $4 AND team_id = $5`,
store.TeamTaskStatusCompleted, now,
taskID, store.TeamTaskStatusInReview, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $3
WHERE id = $4 AND status = $5 AND team_id = $6`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusInReview, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
+166
View File
@@ -0,0 +1,166 @@
package pg
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// Progress
// ============================================================
func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
if percent < 0 || percent > 100 {
return fmt.Errorf("progress percent must be 0-100, got %d", percent)
}
// Also renews lock_expires_at as a heartbeat.
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4
WHERE id = $5 AND status = $6 AND team_id = $7`,
percent, step, lockExpires, now,
taskID, store.TeamTaskStatusInProgress, teamID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// RenewTaskLock extends the lock expiration for an in-progress task.
// Called periodically by the consumer as a heartbeat to prevent
// the ticker from recovering a long-running task.
func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET lock_expires_at = $1, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status = $5`,
lockExpires, now,
taskID, teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// ============================================================
// Stale recovery
// ============================================================
func (s *PGTeamStore) RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE team_id = $3 AND status = $4 AND lock_expires_at IS NOT NULL AND lock_expires_at < $2`,
store.TeamTaskStatusPending, now,
teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
updated_at = $2
WHERE team_id = $3 AND status = $4`,
store.TeamTaskStatusPending, now,
teamID, store.TeamTaskStatusInProgress,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1
AND (
t.status = $2
OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4)
)
ORDER BY t.priority DESC, t.created_at
LIMIT $5`,
teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *PGTeamStore) MarkStaleTasks(ctx context.Context, teamID uuid.UUID, olderThan time.Time) (int, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, updated_at = $2
WHERE team_id = $3 AND status = $4 AND updated_at < $5`,
store.TeamTaskStatusStale, now,
teamID, store.TeamTaskStatusPending, olderThan,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`,
store.TeamTaskStatusPending, now,
taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for reset (not stale/failed or wrong team)")
}
return nil
}
+2
View File
@@ -323,6 +323,8 @@ type TeamStore interface {
ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string) ([]TeamTaskData, error)
// GetTask returns a single task by ID with joined agent info.
GetTask(ctx context.Context, taskID uuid.UUID) (*TeamTaskData, error)
// GetTasksByIDs returns multiple tasks by IDs in a single query.
GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]TeamTaskData, error)
// SearchTasks performs FTS search over task subject+description.
// userID: if non-empty, filter to tasks created by this user.
SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]TeamTaskData, error)
+153
View File
@@ -0,0 +1,153 @@
package tools
import (
"context"
"fmt"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
followupMessage, _ := args["text"].(string)
if followupMessage == "" {
return ErrorResult("text is required for ask_user action (the question for the user)")
}
// Verify ownership.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
return ErrorResult("only the task owner can set follow-up reminders")
}
// Resolve delay and max from team settings.
delayMinutes := t.manager.followupDelayMinutes(team)
maxReminders := t.manager.followupMaxReminders(team)
// Resolve channel: prefer task's channel, fallback to context channel.
channel := task.Channel
chatID := task.ChatID
ctxChannel := ToolChannelFromCtx(ctx)
if channel == "" || channel == ChannelDelegate || channel == ChannelSystem || channel == ChannelDashboard {
channel = ctxChannel
chatID = ToolChatIDFromCtx(ctx)
}
if channel == "" || channel == ChannelDelegate || channel == ChannelSystem || channel == ChannelDashboard {
return ErrorResult("cannot set follow-up: no valid channel found (task has no origin channel and context channel is internal)")
}
followupAt := time.Now().Add(time.Duration(delayMinutes) * time.Minute)
if err := t.manager.teamStore.SetTaskFollowup(ctx, taskID, team.ID, followupAt, maxReminders, followupMessage, channel, chatID); err != nil {
return ErrorResult("failed to set follow-up: " + err.Error())
}
maxDesc := "unlimited"
if maxReminders > 0 {
maxDesc = fmt.Sprintf("max %d", maxReminders)
}
return NewResult(fmt.Sprintf("Follow-up set for task %s. First reminder in %d minutes via %s (%s).", taskID, delayMinutes, channel, maxDesc))
}
func (t *TeamTasksTool) executeClearAskUser(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Allow owner or lead to clear.
if task.OwnerAgentID == nil || (*task.OwnerAgentID != agentID && agentID != team.LeadAgentID) {
return ErrorResult("only the task owner or team lead can clear follow-up reminders")
}
if err := t.manager.teamStore.ClearTaskFollowup(ctx, taskID); err != nil {
return ErrorResult("failed to clear follow-up: " + err.Error())
}
return NewResult(fmt.Sprintf("Follow-up reminders cleared for task %s.", taskID))
}
func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.Status != store.TeamTaskStatusStale && task.Status != store.TeamTaskStatusFailed {
return ErrorResult(fmt.Sprintf("retry only works on stale or failed tasks (current status: %s)", task.Status))
}
if task.OwnerAgentID == nil {
return ErrorResult("task has no assignee — assign it first via update")
}
// Reset status to pending first (AssignTask only transitions from pending).
if err := t.manager.teamStore.ResetTaskStatus(ctx, taskID, team.ID); err != nil {
return ErrorResult("failed to reset task: " + err.Error())
}
// Assign (pending → in_progress + lock).
if err := t.manager.teamStore.AssignTask(ctx, taskID, *task.OwnerAgentID, team.ID); err != nil {
return ErrorResult("failed to retry task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, *task.OwnerAgentID),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
// Dispatch immediately (retry is an explicit action, not during a turn).
t.manager.dispatchTaskToAgent(ctx, task, team.ID, *task.OwnerAgentID)
return NewResult(fmt.Sprintf("Task %s retried and dispatched to %s.", taskID, t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)))
}
+331
View File
@@ -0,0 +1,331 @@
package tools
import (
"context"
"fmt"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID); err != nil {
return ErrorResult("failed to claim task: " + err.Error())
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: ownerKey,
})
return NewResult(fmt.Sprintf("Task %s claimed successfully. It is now in progress.", taskID))
}
func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result {
// Delegate agents cannot complete tasks — autoCompleteTeamTask handles it.
if ToolChannelFromCtx(ctx) == ChannelDelegate {
return ErrorResult("delegate agents cannot complete team tasks directly — results are auto-completed when delegation finishes")
}
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
result, _ := args["result"].(string)
if result == "" {
return ErrorResult("result is required for complete action")
}
// Auto-claim if the task is still pending (saves an extra tool call).
// ClaimTask is atomic — only one agent can succeed, others get an error.
// Ignore claim error: task may already be in_progress (claimed by us or someone else).
_ = t.manager.teamStore.ClaimTask(ctx, taskID, agentID, team.ID)
if err := t.manager.teamStore.CompleteTask(ctx, taskID, team.ID, result); err != nil {
return ErrorResult("failed to complete task: " + err.Error())
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: ownerKey,
})
// Immediately dispatch any newly-unblocked tasks.
t.manager.DispatchUnblockedTasks(ctx, team.ID)
return NewResult(fmt.Sprintf("Task %s completed. Dependent tasks have been unblocked.", taskID))
}
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
// Delegate agents cannot cancel tasks — only lead/user-facing agents can.
if ToolChannelFromCtx(ctx) == ChannelDelegate {
return ErrorResult("delegate agents cannot cancel team tasks directly")
}
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
reason, _ := args["text"].(string)
if reason == "" {
reason = "Cancelled by agent"
}
// CancelTask: guards against completed tasks, unblocks dependents, transitions blocked→pending.
if err := t.manager.teamStore.CancelTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to cancel task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusCancelled,
Reason: reason,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
// Immediately dispatch any newly-unblocked tasks.
t.manager.DispatchUnblockedTasks(ctx, team.ID)
return NewResult(fmt.Sprintf("Task %s cancelled. Any running delegation has been stopped and dependent tasks unblocked.", taskID))
}
func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify the agent owns this task.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
return ErrorResult("only the task owner can submit for review")
}
if err := t.manager.teamStore.ReviewTask(ctx, taskID, team.ID); err != nil {
return ErrorResult("failed to submit for review: " + err.Error())
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInReview,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: ownerKey,
})
return NewResult(fmt.Sprintf("Task %s submitted for review.", taskID))
}
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
// Delegate agents cannot approve tasks — approval requires user authority.
if ToolChannelFromCtx(ctx) == ChannelDelegate {
return ErrorResult("delegate agents cannot approve team tasks")
}
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
// Only lead can approve tasks via tool (non-lead agents should not approve).
// System/dashboard channels bypass this check (human UI approval).
ch := ToolChannelFromCtx(ctx)
if ch != ChannelSystem && ch != ChannelDashboard {
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Fetch task for subject (used in lead message) and team ownership check
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Atomic transition: in_review -> completed
if err := t.manager.teamStore.ApproveTask(ctx, taskID, team.ID, ""); err != nil {
return ErrorResult("failed to approve task: " + err.Error())
}
// Re-fetch to get the actual post-approval status (pending or blocked)
approved, _ := t.manager.teamStore.GetTask(ctx, taskID)
newStatus := store.TeamTaskStatusPending
if approved != nil {
newStatus = approved.Status
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: newStatus,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
// Inject message to lead agent via mailbox
msg := fmt.Sprintf("Task '%s' (id=%s) has been approved by the user (status: %s).", task.Subject, task.ID, newStatus)
_ = t.manager.teamStore.SendMessage(ctx, &store.TeamMessageData{
TeamID: team.ID,
FromAgentID: team.LeadAgentID,
ToAgentID: &team.LeadAgentID,
Content: msg,
MessageType: store.TeamMessageTypeChat,
TaskID: &taskID,
})
return NewResult(fmt.Sprintf("Task %s approved (status: %s).", taskID, newStatus))
}
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
// Delegate agents cannot reject tasks.
if ToolChannelFromCtx(ctx) == ChannelDelegate {
return ErrorResult("delegate agents cannot reject team tasks")
}
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
// Only lead can reject tasks via tool.
ch := ToolChannelFromCtx(ctx)
if ch != ChannelSystem && ch != ChannelDashboard {
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
reason, _ := args["text"].(string)
if reason == "" {
reason = "Rejected by user"
}
// Fetch task to get subject for the lead message
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Reuse CancelTask (handles unblocking dependents, guards against completed)
if err := t.manager.teamStore.CancelTask(ctx, taskID, team.ID, reason); err != nil {
return ErrorResult("failed to reject task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: "cancelled",
Reason: reason,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
// Inject message to lead agent via mailbox
leadMsg := fmt.Sprintf("Task '%s' (id=%s) was rejected by the user. Reason: %s", task.Subject, task.ID, reason)
_ = t.manager.teamStore.SendMessage(ctx, &store.TeamMessageData{
TeamID: team.ID,
FromAgentID: team.LeadAgentID,
ToAgentID: &team.LeadAgentID,
Content: leadMsg,
MessageType: store.TeamMessageTypeChat,
TaskID: &taskID,
})
return NewResult(fmt.Sprintf("Task %s rejected. Dependent tasks have been unblocked.", taskID))
}
+433
View File
@@ -0,0 +1,433 @@
package tools
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
subject, _ := args["subject"].(string)
if subject == "" {
return ErrorResult("subject is required for create action")
}
description, _ := args["description"].(string)
priority := 0
if p, ok := args["priority"].(float64); ok {
priority = int(p)
}
var blockedBy []uuid.UUID
if raw, ok := args["blocked_by"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
}
blockedBy = append(blockedBy, id)
}
}
}
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.teamStore.GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
if depTask.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
}
switch depTask.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Do not block on finished tasks — create this task without blocked_by, "+
"or pass the completed task's result in the description instead.",
depID, depTask.Subject, depTask.Status))
}
}
// Resolve optional assignee (agent key → UUID). Must be a team member.
var assigneeID uuid.UUID
if assigneeKey, _ := args["assignee"].(string); assigneeKey != "" {
aid, err := t.manager.resolveAgentByKey(assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
// Verify assignee is a member of this team.
members, err := t.manager.cachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == aid {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
}
assigneeID = aid
}
requireApproval, _ := args["require_approval"].(bool)
status := store.TeamTaskStatusPending
if requireApproval {
status = store.TeamTaskStatusInReview
} else if len(blockedBy) > 0 {
status = store.TeamTaskStatusBlocked
}
// Assigned tasks without blockers stay pending — dispatched after the turn
// ends via post-turn processing (avoids race with blocked_by setup).
chatID := ToolChatIDFromCtx(ctx)
// Compute the team workspace directory so member agents write files to the
// shared team folder (teams/{teamID}/{chatID}/) instead of their own personal workspace.
// This aligns write_file/create_image with workspace_read/workspace_write paths.
taskMeta := make(map[string]any)
if teamWsDir, err := workspaceDir(t.manager.dataDir, team.ID, "", chatID); err == nil {
taskMeta["team_workspace"] = teamWsDir
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
for i, id := range blockedBy {
ids[i] = id.String()
}
taskMeta["original_blocked_by"] = ids
}
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
taskMeta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
taskMeta["origin_root_span_id"] = rootSpanID.String()
}
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: status,
BlockedBy: blockedBy,
Priority: priority,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: "general",
CreatedByAgentID: &agentID,
ChatID: chatID,
Metadata: taskMeta,
}
if assigneeID != uuid.Nil {
task.OwnerAgentID = &assigneeID
}
if err := t.manager.teamStore.CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
agentKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: status,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: agentKey,
})
if assigneeID != uuid.Nil {
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Status: status,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: agentKey,
})
}
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
if assigneeID != uuid.Nil && status == store.TeamTaskStatusPending {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "fallback_dispatch",
})
t.manager.dispatchTaskToAgent(ctx, task, team.ID, assigneeID)
}
}
}
return NewResult(fmt.Sprintf("Task created: %s (id=%s, identifier=%s, status=%s)", subject, task.ID, task.Identifier, status))
}
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text is required for comment action")
}
if len(text) > 10000 {
return ErrorResult("comment text too long (max 10000 chars)")
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: text,
}); err != nil {
return ErrorResult("failed to add comment: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
return NewResult(fmt.Sprintf("Comment added to task %s.", taskID))
}
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
percent := 0
if p, ok := args["percent"].(float64); ok {
percent = int(p)
}
if percent < 0 || percent > 100 {
return ErrorResult("percent must be 0-100")
}
step, _ := args["text"].(string)
// Verify ownership.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
return ErrorResult("only the task owner can update progress")
}
if err := t.manager.teamStore.UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
return ErrorResult("failed to update progress: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInProgress,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
}
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
fileIDStr, _ := args["file_id"].(string)
if fileIDStr == "" {
return ErrorResult("file_id is required for attach action")
}
fileID, err := uuid.Parse(fileIDStr)
if err != nil {
return ErrorResult("invalid file_id")
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if err := t.manager.teamStore.AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
TaskID: taskID,
FileID: fileID,
AddedBy: &agentID,
}); err != nil {
return ErrorResult("failed to attach file: " + err.Error())
}
return NewResult(fmt.Sprintf("File attached to task %s.", taskID))
}
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify task belongs to this team (prevent cross-team update).
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
updates := map[string]any{}
if desc, ok := args["description"].(string); ok {
updates["description"] = desc
}
if subj, ok := args["subject"].(string); ok && subj != "" {
updates["subject"] = subj
}
if raw, ok := args["blocked_by"].([]any); ok {
var blockedBy []uuid.UUID
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID.", s))
}
blockedBy = append(blockedBy, id)
}
}
// Batch-validate all blocker tasks in one query.
if len(blockedBy) > 0 {
depTasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
if err != nil {
return ErrorResult("failed to validate blocked_by: " + err.Error())
}
depMap := make(map[uuid.UUID]*store.TeamTaskData, len(depTasks))
for i := range depTasks {
depMap[depTasks[i].ID] = &depTasks[i]
}
for _, id := range blockedBy {
dt, ok := depMap[id]
if !ok {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found", id))
}
if dt.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", id))
}
switch dt.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Remove it from blocked_by — finished tasks cannot block new work.",
id, dt.Subject, dt.Status))
}
}
}
updates["blocked_by"] = blockedBy
}
if len(updates) == 0 {
return ErrorResult("no updates provided (set description, subject, or blocked_by)")
}
if err := t.manager.teamStore.UpdateTask(ctx, taskID, updates); err != nil {
return ErrorResult("failed to update task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: task.Status,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("Task %s updated.", taskID))
}
+175
View File
@@ -0,0 +1,175 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const listTasksLimit = 20
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
statusFilter, _ := args["status"].(string)
// Delegate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := ToolChannelFromCtx(ctx)
if channel != ChannelDelegate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", "")
if err != nil {
return ErrorResult("failed to list tasks: " + err.Error())
}
// Strip results from list view — use action=get for full detail
for i := range tasks {
tasks[i].Result = nil
}
hasMore := len(tasks) > listTasksLimit
if hasMore {
tasks = tasks[:listTasksLimit]
}
resp := map[string]any{
"tasks": tasks,
"count": len(tasks),
}
if hasMore {
resp["note"] = fmt.Sprintf("Showing first %d tasks. Use action=search with a query to find older tasks.", listTasksLimit)
resp["has_more"] = true
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
// resolveTaskID extracts and validates the task_id from tool arguments.
// Falls back to the dispatched task ID from context when task_id is empty or
// not a valid UUID (agents often pass task_number like "1" instead of the UUID).
func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error) {
taskIDStr, _ := args["task_id"].(string)
// Try parsing as UUID first.
if taskIDStr != "" {
if id, err := uuid.Parse(taskIDStr); err == nil {
return id, nil
}
}
// Fall back to the dispatched team task ID from context.
if ctxID := TeamTaskIDFromCtx(ctx); ctxID != "" {
if id, err := uuid.Parse(ctxID); err == nil {
return id, nil
}
}
if taskIDStr == "" {
return uuid.Nil, fmt.Errorf("task_id is required")
}
return uuid.Nil, fmt.Errorf("invalid task_id %q — use the UUID from task list, not the task number", taskIDStr)
}
func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("failed to get task: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Truncate result for context protection (full result in DB)
const maxResultRunes = 8000
if task.Result != nil {
r := []rune(*task.Result)
if len(r) > maxResultRunes {
s := string(r[:maxResultRunes]) + "..."
task.Result = &s
}
}
// Load comments, events, and attachments for full detail view.
comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID)
events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID)
attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID)
resp := map[string]any{
"task": task,
}
if len(comments) > 0 {
resp["comments"] = comments
}
if len(events) > 0 {
resp["events"] = events
}
if len(attachments) > 0 {
resp["attachments"] = attachments
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
query, _ := args["query"].(string)
if query == "" {
return ErrorResult("query is required for search action")
}
// Delegate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := ToolChannelFromCtx(ctx)
if channel != ChannelDelegate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, 20, filterUserID)
if err != nil {
return ErrorResult("failed to search tasks: " + err.Error())
}
// Show result snippets in search results
const maxSnippetRunes = 500
for i := range tasks {
if tasks[i].Result != nil {
r := []rune(*tasks[i].Result)
if len(r) > maxSnippetRunes {
s := string(r[:maxSnippetRunes]) + "..."
tasks[i].Result = &s
}
}
}
out, _ := json.Marshal(map[string]any{
"tasks": tasks,
"count": len(tasks),
})
return SilentResult(string(out))
}
File diff suppressed because it is too large. Load diff
+183
View File
@@ -0,0 +1,183 @@
package tools
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// resolveTeam returns the team that the calling agent belongs to.
// When ToolTeamIDFromCtx is set (task dispatch), uses that team ID directly
// instead of GetTeamForAgent — prevents wrong team resolution for multi-team agents.
// Uses a TTL cache to avoid repeated DB queries. Access control
// (user/channel) is checked on every call regardless of cache hit.
func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error) {
agentID := store.AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return nil, uuid.Nil, fmt.Errorf("no agent context — team tools require database stores")
}
// If team ID is explicitly set in context (from task dispatch), use it directly.
// This prevents wrong team resolution when an agent belongs to multiple teams.
if teamIDStr := ToolTeamIDFromCtx(ctx); teamIDStr != "" {
teamUUID, err := uuid.Parse(teamIDStr)
if err == nil && teamUUID != uuid.Nil {
team, err := m.teamStore.GetTeam(ctx, teamUUID)
if err != nil {
slog.Warn("workspace: resolveTeam by context ID failed", "team_id", teamIDStr, "error", err)
// Fall through to normal resolution
} else if team != nil {
return team, agentID, nil
}
}
}
// Check cache first
if entry, ok := m.teamCache.Load(agentID); ok {
ce := entry.(*teamCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
// Cache hit — still check access (user/channel vary per call)
userID := store.UserIDFromContext(ctx)
channel := ToolChannelFromCtx(ctx)
if err := checkTeamAccess(ce.team.Settings, userID, channel); err != nil {
return nil, uuid.Nil, err
}
return ce.team, agentID, nil
}
m.teamCache.Delete(agentID) // expired
}
// Cache miss → DB
team, err := m.teamStore.GetTeamForAgent(ctx, agentID)
if err != nil {
slog.Warn("workspace: resolveTeam DB error", "agent_id", agentID, "error", err)
return nil, uuid.Nil, fmt.Errorf("failed to resolve team: %w", err)
}
if team == nil {
slog.Warn("workspace: agent has no team", "agent_id", agentID)
return nil, uuid.Nil, fmt.Errorf("this agent is not part of any team")
}
// Store in cache (load members eagerly to avoid separate DB call later)
members, _ := m.teamStore.ListMembers(ctx, team.ID)
m.teamCache.Store(agentID, &teamCacheEntry{team: team, members: members, cachedAt: time.Now()})
// Check access
userID := store.UserIDFromContext(ctx)
channel := ToolChannelFromCtx(ctx)
if err := checkTeamAccess(team.Settings, userID, channel); err != nil {
return nil, uuid.Nil, err
}
return team, agentID, nil
}
// requireLead checks if the calling agent is the team lead.
// Delegate/system channels bypass this check (they act on behalf of the lead).
func (m *TeamToolManager) requireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
channel := ToolChannelFromCtx(ctx)
if channel == ChannelDelegate || channel == ChannelSystem {
return nil
}
if agentID != team.LeadAgentID {
return fmt.Errorf("only the team lead can perform this action")
}
return nil
}
// InvalidateTeam clears all cached team + member data.
// Called when team membership, settings, or links change.
// Full clear is acceptable because team mutations are rare (admin-initiated).
func (m *TeamToolManager) InvalidateTeam() {
m.teamCache.Range(func(k, _ any) bool { m.teamCache.Delete(k); return true })
}
// InvalidateAgentCache clears all cached agent data (by ID and by key).
// Called via pub/sub when agent data changes (update/delete).
func (m *TeamToolManager) InvalidateAgentCache() {
m.agentCache.Range(func(k, _ any) bool { m.agentCache.Delete(k); return true })
m.agentKeyCache.Range(func(k, _ any) bool { m.agentKeyCache.Delete(k); return true })
}
// cachedGetAgentByID returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
if entry, ok := m.agentCache.Load(id); ok {
ce := entry.(*agentCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
return ce.agent, nil
}
m.agentCache.Delete(id)
}
ag, err := m.agentStore.GetByID(ctx, id)
if err != nil {
return nil, err
}
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
m.agentCache.Store(id, e)
m.agentKeyCache.Store(ag.AgentKey, e)
return ag, nil
}
// cachedGetAgentByKey returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (*store.AgentData, error) {
if entry, ok := m.agentKeyCache.Load(key); ok {
ce := entry.(*agentCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
return ce.agent, nil
}
m.agentKeyCache.Delete(key)
}
ag, err := m.agentStore.GetByKey(ctx, key)
if err != nil {
return nil, err
}
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
m.agentKeyCache.Store(key, e)
m.agentCache.Store(ag.ID, e)
return ag, nil
}
// cachedListMembers returns members from the team cache if available, or falls back to DB.
func (m *TeamToolManager) cachedListMembers(ctx context.Context, teamID uuid.UUID, agentID uuid.UUID) ([]store.TeamMemberData, error) {
if entry, ok := m.teamCache.Load(agentID); ok {
ce := entry.(*teamCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL && ce.team.ID == teamID && ce.members != nil {
return ce.members, nil
}
}
return m.teamStore.ListMembers(ctx, teamID)
}
// resolveAgentByKey looks up an agent by key and returns its UUID.
func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) {
ag, err := m.cachedGetAgentByKey(context.Background(), key)
if err != nil {
return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err)
}
return ag.ID, nil
}
// agentKeyFromID returns the agent_key for a given UUID.
func (m *TeamToolManager) agentKeyFromID(ctx context.Context, id uuid.UUID) string {
ag, err := m.cachedGetAgentByID(ctx, id)
if err != nil {
return id.String()
}
return ag.AgentKey
}
// taskTeamWorkspace extracts the team_workspace path from task metadata.
func taskTeamWorkspace(task *store.TeamTaskData) string {
if task.Metadata == nil {
return ""
}
ws, _ := task.Metadata["team_workspace"].(string)
return ws
}
+241
View File
@@ -0,0 +1,241 @@
package tools
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// maxTaskDispatches is the max number of times a single task can be dispatched
// before it auto-fails. Prevents infinite loops when agents can't complete a task.
const maxTaskDispatches = 3
// dispatchTaskToAgent publishes a teammate-style inbound message so the
// gateway consumer picks it up and runs the assigned agent, then auto-completes
// the task on success or auto-fails on error.
//
// Routing uses task.Channel/task.ChatID (set at creation time) as primary source,
// falling back to ctx only for initial dispatch when the task is created and dispatched
// in the same call. This ensures correct routing even when called from
// DispatchUnblockedTasks (where ctx is the member agent's context, not the lead's).
func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, teamID, agentID uuid.UUID) {
if m.msgBus == nil {
return
}
// Circuit breaker: auto-fail tasks that have been dispatched too many times.
dispatchCount := 0
if dc, ok := task.Metadata["dispatch_count"].(float64); ok {
dispatchCount = int(dc)
}
if dispatchCount >= maxTaskDispatches {
slog.Warn("team_tasks.dispatch: max dispatch count reached, auto-failing task",
"task_id", task.ID, "dispatch_count", dispatchCount)
failReason := fmt.Sprintf("Task auto-failed after %d dispatch attempts", dispatchCount)
_ = m.teamStore.UpdateTask(ctx, task.ID, map[string]any{
"status": store.TeamTaskStatusFailed,
"result": failReason,
})
return
}
// Increment dispatch count in metadata.
if task.Metadata == nil {
task.Metadata = make(map[string]any)
}
task.Metadata["dispatch_count"] = dispatchCount + 1
_ = m.teamStore.UpdateTask(ctx, task.ID, map[string]any{"metadata": task.Metadata})
ag, err := m.cachedGetAgentByID(ctx, agentID)
if err != nil {
slog.Warn("team_tasks.dispatch: cannot resolve agent", "agent_id", agentID, "error", err)
return
}
content := fmt.Sprintf("[Assigned task #%d (id: %s)]: %s", task.TaskNumber, task.ID, task.Subject)
if task.Description != "" {
content += "\n\n" + task.Description
}
// Hint: tell the agent it's on a team task and where the shared workspace is.
if ws := taskTeamWorkspace(task); ws != "" {
content += fmt.Sprintf("\n\n[Team workspace: %s — all files you create will be saved here, accessible by the team lead and other members via workspace_read.]", ws)
}
// Use task's stored channel/chat as primary source for routing.
// Falls back to ctx values for initial dispatch (task just created, fields match ctx).
originChannel := task.Channel
if originChannel == "" {
originChannel = ToolChannelFromCtx(ctx)
}
originChatID := task.ChatID
if originChatID == "" {
originChatID = ToolChatIDFromCtx(ctx)
}
// Resolve lead agent key for completion announce routing.
fromAgent := ToolAgentKeyFromCtx(ctx)
if team, err := m.teamStore.GetTeamForAgent(ctx, store.AgentIDFromContext(ctx)); err == nil && team != nil {
if leadAg, err := m.cachedGetAgentByID(ctx, team.LeadAgentID); err == nil {
fromAgent = leadAg.AgentKey
}
}
// Resolve user ID: prefer context (available during leader's turn),
// fall back to task's chat ID (stable for dispatches from consumer/ticker context).
originUserID := store.UserIDFromContext(ctx)
if originUserID == "" {
originUserID = originChatID
}
meta := map[string]string{
"origin_channel": originChannel,
"origin_peer_kind": "direct",
"origin_chat_id": originChatID,
"origin_user_id": originUserID,
"from_agent": fromAgent,
"to_agent": ag.AgentKey,
"team_task_id": task.ID.String(),
"team_id": teamID.String(),
}
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
meta["origin_local_key"] = localKey
}
// Pass the team workspace dir so member agents write files to the shared folder.
if ws := taskTeamWorkspace(task); ws != "" {
meta["team_workspace"] = ws
}
// Propagate trace context so member agent's trace links back to the lead's trace,
// and the announce-back run nests under the lead's root span.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
meta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
meta["origin_root_span_id"] = rootSpanID.String()
}
if !m.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "teammate:dashboard",
ChatID: teamID.String(),
Content: content,
UserID: originUserID,
AgentID: ag.AgentKey,
Metadata: meta,
}) {
slog.Warn("team_tasks.dispatch: inbound buffer full, task dispatch dropped — ticker will retry",
"task_id", task.ID, "agent_key", ag.AgentKey)
return
}
slog.Info("team_tasks.dispatch: sent task to agent",
"task_id", task.ID,
"agent_key", ag.AgentKey,
"team_id", teamID,
)
}
// buildBlockerResultsSummary fetches completed blocker tasks (stored in metadata
// during creation) and formats their results for inclusion in the dispatch content.
func (m *TeamToolManager) buildBlockerResultsSummary(ctx context.Context, task *store.TeamTaskData) string {
if task.Metadata == nil {
return ""
}
rawBlockers, ok := task.Metadata["original_blocked_by"]
if !ok {
return ""
}
blockerStrs, ok := rawBlockers.([]any)
if !ok || len(blockerStrs) == 0 {
return ""
}
var parts []string
for _, raw := range blockerStrs {
idStr, ok := raw.(string)
if !ok {
continue
}
blockerID, err := uuid.Parse(idStr)
if err != nil {
continue
}
bt, err := m.teamStore.GetTask(ctx, blockerID)
if err != nil || bt == nil || bt.Result == nil {
continue
}
parts = append(parts, fmt.Sprintf("--- Result from blocker task #%d (%s) ---\n%s",
bt.TaskNumber, bt.Subject, *bt.Result))
}
if len(parts) == 0 {
return ""
}
return "=== Completed blocker task results ===\n\n" + strings.Join(parts, "\n\n")
}
// restoreTraceContext returns a context with the leader's trace IDs restored
// from task metadata. This is needed because DispatchUnblockedTasks runs in
// the member agent's context (during executeComplete), but the dispatch should
// link back to the leader's trace, not the member's.
func (m *TeamToolManager) restoreTraceContext(ctx context.Context, task *store.TeamTaskData) context.Context {
if task.Metadata == nil {
return ctx
}
if traceIDStr, ok := task.Metadata["origin_trace_id"].(string); ok {
if traceID, err := uuid.Parse(traceIDStr); err == nil {
ctx = tracing.WithTraceID(ctx, traceID)
}
}
if spanIDStr, ok := task.Metadata["origin_root_span_id"].(string); ok {
if spanID, err := uuid.Parse(spanIDStr); err == nil {
ctx = tracing.WithParentSpanID(ctx, spanID)
}
}
return ctx
}
// DispatchUnblockedTasks finds pending tasks with an assigned owner, claims them
// (pending → in_progress), and dispatches them immediately.
// Called after task completion/cancellation to start newly-unblocked work
// instead of waiting for the ticker (up to 5 min delay).
func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uuid.UUID) {
tasks, err := m.teamStore.ListRecoverableTasks(ctx, teamID)
if err != nil {
return
}
for i := range tasks {
task := &tasks[i]
if task.Status == store.TeamTaskStatusPending && task.OwnerAgentID != nil {
// Assign (pending → in_progress + lock) so consumer can auto-complete.
if err := m.teamStore.AssignTask(ctx, task.ID, *task.OwnerAgentID, teamID); err != nil {
slog.Warn("DispatchUnblockedTasks: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "dispatch_unblocked",
})
// Append completed blocker results so the member agent has context.
if summary := m.buildBlockerResultsSummary(ctx, task); summary != "" {
task.Description += "\n\n" + summary
}
// Restore leader's trace context (stored in task metadata during creation)
// so the member agent's trace links back to the leader, not the completing member.
dispatchCtx := m.restoreTraceContext(ctx, task)
m.dispatchTaskToAgent(dispatchCtx, task, teamID, *task.OwnerAgentID)
}
}
}
+225
View File
@@ -0,0 +1,225 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// broadcastTeamEvent sends a real-time event via the message bus for team activity visibility.
func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) {
if m.msgBus == nil {
return
}
m.msgBus.Broadcast(bus.Event{
Name: name,
Payload: payload,
})
}
// resolveTeamRole returns the calling agent's role in the team.
// Unlike requireLead(), this does NOT bypass for delegate channel —
// workspace RBAC must respect actual roles even during delegation.
func (m *TeamToolManager) resolveTeamRole(ctx context.Context, team *store.TeamData, agentID uuid.UUID) (string, error) {
if agentID == team.LeadAgentID {
return store.TeamRoleLead, nil
}
members, err := m.cachedListMembers(ctx, team.ID, agentID)
if err != nil {
return "", fmt.Errorf("failed to resolve team role: %w", err)
}
for _, member := range members {
if member.AgentID == agentID {
return member.Role, nil
}
}
return "", fmt.Errorf("agent is not a member of this team")
}
// agentDisplayName returns the display name for an agent key, falling back to empty string.
func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) string {
ag, err := m.cachedGetAgentByKey(ctx, key)
if err != nil || ag.DisplayName == "" {
return ""
}
return ag.DisplayName
}
// ============================================================
// Version helpers
// ============================================================
// IsTeamV2 checks if team has version >= 2 in settings.
// Returns false for nil team, nil/empty settings, or version < 2.
func IsTeamV2(team *store.TeamData) bool {
if team == nil || team.Settings == nil {
return false
}
var s struct {
Version int `json:"version"`
}
if json.Unmarshal(team.Settings, &s) != nil {
return false
}
return s.Version >= 2
}
// ============================================================
// Follow-up settings helpers
// ============================================================
const (
defaultFollowupDelayMinutes = 30
defaultFollowupMaxReminders = 0 // 0 = unlimited
)
// followupDelayMinutes returns the team's followup_interval_minutes setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
return defaultFollowupDelayMinutes
}
var settings map[string]any
if json.Unmarshal(team.Settings, &settings) != nil {
return defaultFollowupDelayMinutes
}
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
return int(v)
}
return defaultFollowupDelayMinutes
}
// followupMaxReminders returns the team's followup_max_reminders setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupMaxReminders(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
return defaultFollowupMaxReminders
}
var settings map[string]any
if json.Unmarshal(team.Settings, &settings) != nil {
return defaultFollowupMaxReminders
}
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
return int(v)
}
return defaultFollowupMaxReminders
}
// ============================================================
// Escalation policy
// ============================================================
// EscalationResult indicates how an action should be handled.
type EscalationResult int
const (
EscalationNone EscalationResult = iota // no escalation configured
EscalationAuto // LLM chooses (currently: always review)
EscalationReview // create review task
EscalationReject // reject outright
)
// checkEscalation parses the team's escalation_mode and escalation_actions settings.
// Returns EscalationNone for v1 teams.
func (m *TeamToolManager) checkEscalation(team *store.TeamData, action string) EscalationResult {
if !IsTeamV2(team) {
return EscalationNone
}
if team.Settings == nil {
return EscalationNone
}
var settings map[string]any
if err := json.Unmarshal(team.Settings, &settings); err != nil {
return EscalationNone
}
mode, _ := settings["escalation_mode"].(string)
if mode == "" {
return EscalationNone
}
// Check if action is in escalation_actions list.
actionsRaw, _ := settings["escalation_actions"].([]any)
if len(actionsRaw) > 0 {
found := false
for _, a := range actionsRaw {
if s, ok := a.(string); ok && s == action {
found = true
break
}
}
if !found {
return EscalationNone
}
}
switch mode {
case "auto":
return EscalationAuto
case "review":
return EscalationReview
case "reject":
return EscalationReject
default:
return EscalationNone
}
}
// createEscalationTask creates an escalation task and broadcasts the event.
func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.TeamData, agentID uuid.UUID, subject, description string) *Result {
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: store.TeamTaskStatusPending,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: "escalation",
CreatedByAgentID: &agentID,
ChatID: ToolChatIDFromCtx(ctx),
}
if err := m.teamStore.CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create escalation task: " + err.Error())
}
m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: store.TeamTaskStatusPending,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
})
// Notify channel if possible.
m.notifyChannelReview(task)
return NewResult(fmt.Sprintf("Action requires approval. Escalation task created: %s (id=%s). A human must approve before this action can proceed.", subject, task.Identifier))
}
// notifyChannelReview publishes an outbound message to the origin channel about a pending review.
func (m *TeamToolManager) notifyChannelReview(task *store.TeamTaskData) {
if m.msgBus == nil || task.Channel == "" || task.ChatID == "" {
return
}
content := fmt.Sprintf("🔔 Escalation: \"%s\" requires human review (task %s).", task.Subject, task.Identifier)
m.msgBus.PublishOutbound(bus.OutboundMessage{
Channel: task.Channel,
ChatID: task.ChatID,
Content: content,
})
}
+5 -882
View File
@@ -1,20 +1,11 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const teamCacheTTL = 5 * time.Minute
@@ -37,11 +28,11 @@ type agentCacheEntry struct {
// the team store, agent store, and message bus.
// Includes a TTL cache for team data to avoid DB queries on every tool call.
type TeamToolManager struct {
teamStore store.TeamStore
agentStore store.AgentStore
msgBus *bus.MessageBus
dataDir string // base data directory for workspace path resolution
teamCache sync.Map // agentID (uuid.UUID) → *teamCacheEntry
teamStore store.TeamStore
agentStore store.AgentStore
msgBus *bus.MessageBus
dataDir string // base data directory for workspace path resolution
teamCache sync.Map // agentID (uuid.UUID) → *teamCacheEntry
agentCache sync.Map // agentID (uuid.UUID) → *agentCacheEntry
agentKeyCache sync.Map // agentKey (string) → *agentCacheEntry
}
@@ -49,871 +40,3 @@ type TeamToolManager struct {
func NewTeamToolManager(teamStore store.TeamStore, agentStore store.AgentStore, msgBus *bus.MessageBus, dataDir string) *TeamToolManager {
return &TeamToolManager{teamStore: teamStore, agentStore: agentStore, msgBus: msgBus, dataDir: dataDir}
}
// resolveTeam returns the team that the calling agent belongs to.
// When ToolTeamIDFromCtx is set (task dispatch), uses that team ID directly
// instead of GetTeamForAgent — prevents wrong team resolution for multi-team agents.
// Uses a TTL cache to avoid repeated DB queries. Access control
// (user/channel) is checked on every call regardless of cache hit.
func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error) {
agentID := store.AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return nil, uuid.Nil, fmt.Errorf("no agent context — team tools require database stores")
}
// If team ID is explicitly set in context (from task dispatch), use it directly.
// This prevents wrong team resolution when an agent belongs to multiple teams.
if teamIDStr := ToolTeamIDFromCtx(ctx); teamIDStr != "" {
teamUUID, err := uuid.Parse(teamIDStr)
if err == nil && teamUUID != uuid.Nil {
team, err := m.teamStore.GetTeam(ctx, teamUUID)
if err != nil {
slog.Warn("workspace: resolveTeam by context ID failed", "team_id", teamIDStr, "error", err)
// Fall through to normal resolution
} else if team != nil {
return team, agentID, nil
}
}
}
// Check cache first
if entry, ok := m.teamCache.Load(agentID); ok {
ce := entry.(*teamCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
// Cache hit — still check access (user/channel vary per call)
userID := store.UserIDFromContext(ctx)
channel := ToolChannelFromCtx(ctx)
if err := checkTeamAccess(ce.team.Settings, userID, channel); err != nil {
return nil, uuid.Nil, err
}
return ce.team, agentID, nil
}
m.teamCache.Delete(agentID) // expired
}
// Cache miss → DB
team, err := m.teamStore.GetTeamForAgent(ctx, agentID)
if err != nil {
slog.Warn("workspace: resolveTeam DB error", "agent_id", agentID, "error", err)
return nil, uuid.Nil, fmt.Errorf("failed to resolve team: %w", err)
}
if team == nil {
slog.Warn("workspace: agent has no team", "agent_id", agentID)
return nil, uuid.Nil, fmt.Errorf("this agent is not part of any team")
}
// Store in cache (load members eagerly to avoid separate DB call later)
members, _ := m.teamStore.ListMembers(ctx, team.ID)
m.teamCache.Store(agentID, &teamCacheEntry{team: team, members: members, cachedAt: time.Now()})
// Check access
userID := store.UserIDFromContext(ctx)
channel := ToolChannelFromCtx(ctx)
if err := checkTeamAccess(team.Settings, userID, channel); err != nil {
return nil, uuid.Nil, err
}
return team, agentID, nil
}
// requireLead checks if the calling agent is the team lead.
// Delegate/system channels bypass this check (they act on behalf of the lead).
func (m *TeamToolManager) requireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
channel := ToolChannelFromCtx(ctx)
if channel == ChannelDelegate || channel == ChannelSystem {
return nil
}
if agentID != team.LeadAgentID {
return fmt.Errorf("only the team lead can perform this action")
}
return nil
}
// InvalidateTeam clears all cached team + member data.
// Called when team membership, settings, or links change.
// Full clear is acceptable because team mutations are rare (admin-initiated).
func (m *TeamToolManager) InvalidateTeam() {
m.teamCache.Range(func(k, _ any) bool { m.teamCache.Delete(k); return true })
}
// InvalidateAgentCache clears all cached agent data (by ID and by key).
// Called via pub/sub when agent data changes (update/delete).
func (m *TeamToolManager) InvalidateAgentCache() {
m.agentCache.Range(func(k, _ any) bool { m.agentCache.Delete(k); return true })
m.agentKeyCache.Range(func(k, _ any) bool { m.agentKeyCache.Delete(k); return true })
}
// cachedGetAgentByID returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
if entry, ok := m.agentCache.Load(id); ok {
ce := entry.(*agentCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
return ce.agent, nil
}
m.agentCache.Delete(id)
}
ag, err := m.agentStore.GetByID(ctx, id)
if err != nil {
return nil, err
}
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
m.agentCache.Store(id, e)
m.agentKeyCache.Store(ag.AgentKey, e)
return ag, nil
}
// cachedGetAgentByKey returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (*store.AgentData, error) {
if entry, ok := m.agentKeyCache.Load(key); ok {
ce := entry.(*agentCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
return ce.agent, nil
}
m.agentKeyCache.Delete(key)
}
ag, err := m.agentStore.GetByKey(ctx, key)
if err != nil {
return nil, err
}
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
m.agentKeyCache.Store(key, e)
m.agentCache.Store(ag.ID, e)
return ag, nil
}
// cachedListMembers returns members from the team cache if available, or falls back to DB.
func (m *TeamToolManager) cachedListMembers(ctx context.Context, teamID uuid.UUID, agentID uuid.UUID) ([]store.TeamMemberData, error) {
if entry, ok := m.teamCache.Load(agentID); ok {
ce := entry.(*teamCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL && ce.team.ID == teamID && ce.members != nil {
return ce.members, nil
}
}
return m.teamStore.ListMembers(ctx, teamID)
}
// resolveAgentByKey looks up an agent by key and returns its UUID.
func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) {
ag, err := m.cachedGetAgentByKey(context.Background(), key)
if err != nil {
return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err)
}
return ag.ID, nil
}
// agentKeyFromID returns the agent_key for a given UUID.
func (m *TeamToolManager) agentKeyFromID(ctx context.Context, id uuid.UUID) string {
ag, err := m.cachedGetAgentByID(ctx, id)
if err != nil {
return id.String()
}
return ag.AgentKey
}
// taskTeamWorkspace extracts the team_workspace path from task metadata.
func taskTeamWorkspace(task *store.TeamTaskData) string {
if task.Metadata == nil {
return ""
}
ws, _ := task.Metadata["team_workspace"].(string)
return ws
}
// dispatchTaskToAgent publishes a teammate-style inbound message so the
// gateway consumer picks it up and runs the assigned agent, then auto-completes
// the task on success or auto-fails on error.
//
// Routing uses task.Channel/task.ChatID (set at creation time) as primary source,
// falling back to ctx only for initial dispatch when the task is created and dispatched
// in the same call. This ensures correct routing even when called from
// DispatchUnblockedTasks (where ctx is the member agent's context, not the lead's).
// maxTaskDispatches is the max number of times a single task can be dispatched
// before it auto-fails. Prevents infinite loops when agents can't complete a task.
const maxTaskDispatches = 3
func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, teamID, agentID uuid.UUID) {
if m.msgBus == nil {
return
}
// Circuit breaker: auto-fail tasks that have been dispatched too many times.
dispatchCount := 0
if dc, ok := task.Metadata["dispatch_count"].(float64); ok {
dispatchCount = int(dc)
}
if dispatchCount >= maxTaskDispatches {
slog.Warn("team_tasks.dispatch: max dispatch count reached, auto-failing task",
"task_id", task.ID, "dispatch_count", dispatchCount)
failReason := fmt.Sprintf("Task auto-failed after %d dispatch attempts", dispatchCount)
_ = m.teamStore.UpdateTask(ctx, task.ID, map[string]any{
"status": store.TeamTaskStatusFailed,
"result": failReason,
})
return
}
// Increment dispatch count in metadata.
if task.Metadata == nil {
task.Metadata = make(map[string]any)
}
task.Metadata["dispatch_count"] = dispatchCount + 1
_ = m.teamStore.UpdateTask(ctx, task.ID, map[string]any{"metadata": task.Metadata})
ag, err := m.cachedGetAgentByID(ctx, agentID)
if err != nil {
slog.Warn("team_tasks.dispatch: cannot resolve agent", "agent_id", agentID, "error", err)
return
}
content := fmt.Sprintf("[Assigned task #%d (id: %s)]: %s", task.TaskNumber, task.ID, task.Subject)
if task.Description != "" {
content += "\n\n" + task.Description
}
// Hint: tell the agent it's on a team task and where the shared workspace is.
if ws := taskTeamWorkspace(task); ws != "" {
content += fmt.Sprintf("\n\n[Team workspace: %s — all files you create will be saved here, accessible by the team lead and other members via workspace_read.]", ws)
}
// Use task's stored channel/chat as primary source for routing.
// Falls back to ctx values for initial dispatch (task just created, fields match ctx).
originChannel := task.Channel
if originChannel == "" {
originChannel = ToolChannelFromCtx(ctx)
}
originChatID := task.ChatID
if originChatID == "" {
originChatID = ToolChatIDFromCtx(ctx)
}
// Resolve lead agent key for completion announce routing.
fromAgent := ToolAgentKeyFromCtx(ctx)
if team, err := m.teamStore.GetTeamForAgent(ctx, store.AgentIDFromContext(ctx)); err == nil && team != nil {
if leadAg, err := m.cachedGetAgentByID(ctx, team.LeadAgentID); err == nil {
fromAgent = leadAg.AgentKey
}
}
// Resolve user ID: prefer context (available during leader's turn),
// fall back to task's chat ID (stable for dispatches from consumer/ticker context).
originUserID := store.UserIDFromContext(ctx)
if originUserID == "" {
originUserID = originChatID
}
meta := map[string]string{
"origin_channel": originChannel,
"origin_peer_kind": "direct",
"origin_chat_id": originChatID,
"origin_user_id": originUserID,
"from_agent": fromAgent,
"to_agent": ag.AgentKey,
"team_task_id": task.ID.String(),
"team_id": teamID.String(),
}
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
meta["origin_local_key"] = localKey
}
// Pass the team workspace dir so member agents write files to the shared folder.
if ws := taskTeamWorkspace(task); ws != "" {
meta["team_workspace"] = ws
}
// Propagate trace context so member agent's trace links back to the lead's trace,
// and the announce-back run nests under the lead's root span.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
meta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
meta["origin_root_span_id"] = rootSpanID.String()
}
if !m.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "teammate:dashboard",
ChatID: teamID.String(),
Content: content,
UserID: originUserID,
AgentID: ag.AgentKey,
Metadata: meta,
}) {
slog.Warn("team_tasks.dispatch: inbound buffer full, task dispatch dropped — ticker will retry",
"task_id", task.ID, "agent_key", ag.AgentKey)
return
}
slog.Info("team_tasks.dispatch: sent task to agent",
"task_id", task.ID,
"agent_key", ag.AgentKey,
"team_id", teamID,
)
}
// buildBlockerResultsSummary fetches completed blocker tasks (stored in metadata
// during creation) and formats their results for inclusion in the dispatch content.
func (m *TeamToolManager) buildBlockerResultsSummary(ctx context.Context, task *store.TeamTaskData) string {
if task.Metadata == nil {
return ""
}
rawBlockers, ok := task.Metadata["original_blocked_by"]
if !ok {
return ""
}
blockerStrs, ok := rawBlockers.([]any)
if !ok || len(blockerStrs) == 0 {
return ""
}
var parts []string
for _, raw := range blockerStrs {
idStr, ok := raw.(string)
if !ok {
continue
}
blockerID, err := uuid.Parse(idStr)
if err != nil {
continue
}
bt, err := m.teamStore.GetTask(ctx, blockerID)
if err != nil || bt == nil || bt.Result == nil {
continue
}
parts = append(parts, fmt.Sprintf("--- Result from blocker task #%d (%s) ---\n%s",
bt.TaskNumber, bt.Subject, *bt.Result))
}
if len(parts) == 0 {
return ""
}
return "=== Completed blocker task results ===\n\n" + strings.Join(parts, "\n\n")
}
// restoreTraceContext returns a context with the leader's trace IDs restored
// from task metadata. This is needed because DispatchUnblockedTasks runs in
// the member agent's context (during executeComplete), but the dispatch should
// link back to the leader's trace, not the member's.
func (m *TeamToolManager) restoreTraceContext(ctx context.Context, task *store.TeamTaskData) context.Context {
if task.Metadata == nil {
return ctx
}
if traceIDStr, ok := task.Metadata["origin_trace_id"].(string); ok {
if traceID, err := uuid.Parse(traceIDStr); err == nil {
ctx = tracing.WithTraceID(ctx, traceID)
}
}
if spanIDStr, ok := task.Metadata["origin_root_span_id"].(string); ok {
if spanID, err := uuid.Parse(spanIDStr); err == nil {
ctx = tracing.WithParentSpanID(ctx, spanID)
}
}
return ctx
}
// DispatchUnblockedTasks finds pending tasks with an assigned owner, claims them
// (pending → in_progress), and dispatches them immediately.
// Called after task completion/cancellation to start newly-unblocked work
// instead of waiting for the ticker (up to 5 min delay).
func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uuid.UUID) {
tasks, err := m.teamStore.ListRecoverableTasks(ctx, teamID)
if err != nil {
return
}
for i := range tasks {
task := &tasks[i]
if task.Status == store.TeamTaskStatusPending && task.OwnerAgentID != nil {
// Assign (pending → in_progress + lock) so consumer can auto-complete.
if err := m.teamStore.AssignTask(ctx, task.ID, *task.OwnerAgentID, teamID); err != nil {
slog.Warn("DispatchUnblockedTasks: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "dispatch_unblocked",
})
// Append completed blocker results so the member agent has context.
if summary := m.buildBlockerResultsSummary(ctx, task); summary != "" {
task.Description += "\n\n" + summary
}
// Restore leader's trace context (stored in task metadata during creation)
// so the member agent's trace links back to the leader, not the completing member.
dispatchCtx := m.restoreTraceContext(ctx, task)
m.dispatchTaskToAgent(dispatchCtx, task, teamID, *task.OwnerAgentID)
}
}
}
// PostTurnProcessor validates and dispatches pending team tasks after an agent turn.
type PostTurnProcessor interface {
ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error
// DispatchUnblockedTasks finds pending tasks with an owner and dispatches them.
// Called by the consumer after auto-completing a task to unblock dependent work.
DispatchUnblockedTasks(ctx context.Context, teamID uuid.UUID)
}
// ProcessPendingTasks validates tasks created during a turn and dispatches unblocked ones.
// Called by the consumer after an agent turn ends.
func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error {
if len(taskIDs) == 0 {
return nil
}
// Fetch all tasks created in this turn.
tasks := make([]*store.TeamTaskData, 0, len(taskIDs))
for _, id := range taskIDs {
t, err := m.teamStore.GetTask(ctx, id)
if err != nil {
slog.Warn("post_turn: cannot fetch task", "task_id", id, "error", err)
continue
}
tasks = append(tasks, t)
}
if len(tasks) == 0 {
return nil
}
// Build lookup: taskID → task (for cycle/ref validation).
taskMap := make(map[uuid.UUID]*store.TeamTaskData, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = t
}
// Validate blocked_by references and detect cycles.
cycled, invalidRef := validateBlockedBy(taskMap)
// Fail tasks with invalid blocked_by references.
for taskID, badRef := range invalidRef {
task := taskMap[taskID]
errMsg := fmt.Sprintf("blocked_by references non-existent task %s", badRef)
if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil {
slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err)
}
m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
slog.Warn("post_turn: task failed — invalid blocked_by",
"task_id", taskID, "identifier", task.Identifier, "bad_ref", badRef)
}
// Fail cycled tasks and notify leader.
if len(cycled) > 0 {
m.failCycledTasks(ctx, teamID, cycled, taskMap)
}
// Dispatch pending assigned tasks (not blocked, not failed).
for _, task := range tasks {
if _, isCycled := cycled[task.ID]; isCycled {
continue
}
if _, isInvalid := invalidRef[task.ID]; isInvalid {
continue
}
if task.Status != store.TeamTaskStatusPending || task.OwnerAgentID == nil {
continue
}
if err := m.teamStore.AssignTask(ctx, task.ID, *task.OwnerAgentID, teamID); err != nil {
slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
// Restore leader's trace context from task metadata (ctx here is the
// consumer goroutine context which has no trace after the turn ends).
dispatchCtx := m.restoreTraceContext(ctx, task)
m.dispatchTaskToAgent(dispatchCtx, task, teamID, *task.OwnerAgentID)
}
slog.Info("post_turn: processed pending tasks",
"team_id", teamID,
"total", len(tasks),
"dispatched", countPendingAssigned(tasks, cycled, invalidRef),
)
return nil
}
// failCycledTasks fails all tasks in the cycle and notifies the leader.
func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, cycled map[uuid.UUID]bool, taskMap map[uuid.UUID]*store.TeamTaskData) {
// Build cycle description using task identifiers.
var ids []string
for id := range cycled {
if t := taskMap[id]; t != nil {
ids = append(ids, t.Identifier)
}
}
cycleDesc := fmt.Sprintf("Circular dependency detected among tasks: %s", strings.Join(ids, " → "))
for id := range cycled {
if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil {
slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err)
}
m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: id.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
}
// Notify leader via system message.
m.notifyLeaderCycleError(ctx, teamID, cycleDesc)
}
// notifyLeaderCycleError sends a system message to the leader about cycled tasks.
func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uuid.UUID, cycleDesc string) {
if m.msgBus == nil {
return
}
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
return
}
leadAgent, err := m.cachedGetAgentByID(ctx, team.LeadAgentID)
if err != nil {
return
}
content := fmt.Sprintf("[System] %s\nPlease recreate these tasks with corrected dependencies.", cycleDesc)
m.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "teammate:system",
ChatID: teamID.String(),
AgentID: leadAgent.AgentKey,
UserID: team.CreatedBy,
Content: content,
Metadata: map[string]string{
"team_id": teamID.String(),
"from_agent": leadAgent.AgentKey,
"to_agent": leadAgent.AgentKey,
},
})
}
// validateBlockedBy checks blocked_by references and detects cycles using Kahn's algorithm.
// Only validates within the batch — out-of-batch blocked_by refs are assumed valid
// (already validated by executeCreate). Self-blocking is caught as invalid.
//
// Note: Cross-turn blocked_by references (tasks from previous turns) work at the DB
// level via unblockDependentTasks (WHERE $1 = ANY(blocked_by)). Cycle detection here
// only considers edges within the current batch — cross-turn deps are external.
//
// Returns: cycled task IDs, and map of taskID → first invalid blocked_by ref.
func validateBlockedBy(taskMap map[uuid.UUID]*store.TeamTaskData) (cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) {
invalidRef = make(map[uuid.UUID]uuid.UUID)
// Collect all task IDs in this batch.
batchIDs := make(map[uuid.UUID]bool, len(taskMap))
for id := range taskMap {
batchIDs[id] = true
}
// Check for self-blocking only. Out-of-batch blocked_by refs are valid
// (tasks from previous turns, already validated by executeCreate).
for id, task := range taskMap {
for _, dep := range task.BlockedBy {
if dep == id {
invalidRef[id] = dep
break
}
}
}
// Cycle detection via Kahn's algorithm.
// Only considers edges within the batch — out-of-batch deps are external
// and don't participate in cycle detection.
inDegree := make(map[uuid.UUID]int)
adj := make(map[uuid.UUID][]uuid.UUID) // blocker → dependents
for id, task := range taskMap {
if _, bad := invalidRef[id]; bad {
continue
}
if _, exists := inDegree[id]; !exists {
inDegree[id] = 0
}
for _, dep := range task.BlockedBy {
if _, bad := invalidRef[dep]; bad {
continue
}
// Only consider edges within the batch for cycle detection.
if !batchIDs[dep] {
continue
}
adj[dep] = append(adj[dep], id)
inDegree[id]++
}
}
// BFS: process nodes with in-degree 0.
var queue []uuid.UUID
for id, deg := range inDegree {
if deg == 0 {
queue = append(queue, id)
}
}
processed := make(map[uuid.UUID]bool)
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
processed[node] = true
for _, dependent := range adj[node] {
inDegree[dependent]--
if inDegree[dependent] == 0 {
queue = append(queue, dependent)
}
}
}
// Any node not processed is part of a cycle.
cycled = make(map[uuid.UUID]bool)
for id := range inDegree {
if !processed[id] {
cycled[id] = true
}
}
return cycled, invalidRef
}
// countPendingAssigned counts tasks that were eligible for dispatch.
func countPendingAssigned(tasks []*store.TeamTaskData, cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) int {
n := 0
for _, t := range tasks {
if _, c := cycled[t.ID]; c {
continue
}
if _, i := invalidRef[t.ID]; i {
continue
}
if t.Status == store.TeamTaskStatusPending && t.OwnerAgentID != nil {
n++
}
}
return n
}
// broadcastTeamEvent sends a real-time event via the message bus for team activity visibility.
func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) {
if m.msgBus == nil {
return
}
m.msgBus.Broadcast(bus.Event{
Name: name,
Payload: payload,
})
}
// resolveTeamRole returns the calling agent's role in the team.
// Unlike requireLead(), this does NOT bypass for delegate channel —
// workspace RBAC must respect actual roles even during delegation.
func (m *TeamToolManager) resolveTeamRole(ctx context.Context, team *store.TeamData, agentID uuid.UUID) (string, error) {
if agentID == team.LeadAgentID {
return store.TeamRoleLead, nil
}
members, err := m.cachedListMembers(ctx, team.ID, agentID)
if err != nil {
return "", fmt.Errorf("failed to resolve team role: %w", err)
}
for _, member := range members {
if member.AgentID == agentID {
return member.Role, nil
}
}
return "", fmt.Errorf("agent is not a member of this team")
}
// agentDisplayName returns the display name for an agent key, falling back to empty string.
func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) string {
ag, err := m.cachedGetAgentByKey(ctx, key)
if err != nil || ag.DisplayName == "" {
return ""
}
return ag.DisplayName
}
// ============================================================
// Version helpers
// ============================================================
// IsTeamV2 checks if team has version >= 2 in settings.
// Returns false for nil team, nil/empty settings, or version < 2.
func IsTeamV2(team *store.TeamData) bool {
if team == nil || team.Settings == nil {
return false
}
var s struct {
Version int `json:"version"`
}
if json.Unmarshal(team.Settings, &s) != nil {
return false
}
return s.Version >= 2
}
// ============================================================
// Follow-up settings helpers
// ============================================================
const (
defaultFollowupDelayMinutes = 30
defaultFollowupMaxReminders = 0 // 0 = unlimited
)
// followupDelayMinutes returns the team's followup_interval_minutes setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
return defaultFollowupDelayMinutes
}
var settings map[string]any
if json.Unmarshal(team.Settings, &settings) != nil {
return defaultFollowupDelayMinutes
}
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
return int(v)
}
return defaultFollowupDelayMinutes
}
// followupMaxReminders returns the team's followup_max_reminders setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupMaxReminders(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
return defaultFollowupMaxReminders
}
var settings map[string]any
if json.Unmarshal(team.Settings, &settings) != nil {
return defaultFollowupMaxReminders
}
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
return int(v)
}
return defaultFollowupMaxReminders
}
// ============================================================
// Escalation policy
// ============================================================
// EscalationResult indicates how an action should be handled.
type EscalationResult int
const (
EscalationNone EscalationResult = iota // no escalation configured
EscalationAuto // LLM chooses (currently: always review)
EscalationReview // create review task
EscalationReject // reject outright
)
// checkEscalation parses the team's escalation_mode and escalation_actions settings.
// Returns EscalationNone for v1 teams.
func (m *TeamToolManager) checkEscalation(team *store.TeamData, action string) EscalationResult {
if !IsTeamV2(team) {
return EscalationNone
}
if team.Settings == nil {
return EscalationNone
}
var settings map[string]any
if err := json.Unmarshal(team.Settings, &settings); err != nil {
return EscalationNone
}
mode, _ := settings["escalation_mode"].(string)
if mode == "" {
return EscalationNone
}
// Check if action is in escalation_actions list.
actionsRaw, _ := settings["escalation_actions"].([]any)
if len(actionsRaw) > 0 {
found := false
for _, a := range actionsRaw {
if s, ok := a.(string); ok && s == action {
found = true
break
}
}
if !found {
return EscalationNone
}
}
switch mode {
case "auto":
return EscalationAuto
case "review":
return EscalationReview
case "reject":
return EscalationReject
default:
return EscalationNone
}
}
// createEscalationTask creates an escalation task and broadcasts the event.
func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.TeamData, agentID uuid.UUID, subject, description string) *Result {
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: store.TeamTaskStatusPending,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: "escalation",
CreatedByAgentID: &agentID,
ChatID: ToolChatIDFromCtx(ctx),
}
if err := m.teamStore.CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create escalation task: " + err.Error())
}
m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: store.TeamTaskStatusPending,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
})
// Notify channel if possible.
m.notifyChannelReview(task)
return NewResult(fmt.Sprintf("Action requires approval. Escalation task created: %s (id=%s). A human must approve before this action can proceed.", subject, task.Identifier))
}
// notifyChannelReview publishes an outbound message to the origin channel about a pending review.
func (m *TeamToolManager) notifyChannelReview(task *store.TeamTaskData) {
if m.msgBus == nil || task.Channel == "" || task.ChatID == "" {
return
}
content := fmt.Sprintf("🔔 Escalation: \"%s\" requires human review (task %s).", task.Subject, task.Identifier)
m.msgBus.PublishOutbound(bus.OutboundMessage{
Channel: task.Channel,
ChatID: task.ChatID,
Content: content,
})
}
+275
View File
@@ -0,0 +1,275 @@
package tools
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// PostTurnProcessor validates and dispatches pending team tasks after an agent turn.
type PostTurnProcessor interface {
ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error
// DispatchUnblockedTasks finds pending tasks with an owner and dispatches them.
// Called by the consumer after auto-completing a task to unblock dependent work.
DispatchUnblockedTasks(ctx context.Context, teamID uuid.UUID)
}
// ProcessPendingTasks validates tasks created during a turn and dispatches unblocked ones.
// Called by the consumer after an agent turn ends.
func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error {
if len(taskIDs) == 0 {
return nil
}
// Fetch all tasks created in this turn.
tasks := make([]*store.TeamTaskData, 0, len(taskIDs))
for _, id := range taskIDs {
t, err := m.teamStore.GetTask(ctx, id)
if err != nil {
slog.Warn("post_turn: cannot fetch task", "task_id", id, "error", err)
continue
}
tasks = append(tasks, t)
}
if len(tasks) == 0 {
return nil
}
// Build lookup: taskID → task (for cycle/ref validation).
taskMap := make(map[uuid.UUID]*store.TeamTaskData, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = t
}
// Validate blocked_by references and detect cycles.
cycled, invalidRef := validateBlockedBy(taskMap)
// Fail tasks with invalid blocked_by references.
for taskID, badRef := range invalidRef {
task := taskMap[taskID]
errMsg := fmt.Sprintf("blocked_by references non-existent task %s", badRef)
if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil {
slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err)
}
m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
slog.Warn("post_turn: task failed — invalid blocked_by",
"task_id", taskID, "identifier", task.Identifier, "bad_ref", badRef)
}
// Fail cycled tasks and notify leader.
if len(cycled) > 0 {
m.failCycledTasks(ctx, teamID, cycled, taskMap)
}
// Dispatch pending assigned tasks (not blocked, not failed).
for _, task := range tasks {
if _, isCycled := cycled[task.ID]; isCycled {
continue
}
if _, isInvalid := invalidRef[task.ID]; isInvalid {
continue
}
if task.Status != store.TeamTaskStatusPending || task.OwnerAgentID == nil {
continue
}
if err := m.teamStore.AssignTask(ctx, task.ID, *task.OwnerAgentID, teamID); err != nil {
slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
// Restore leader's trace context from task metadata (ctx here is the
// consumer goroutine context which has no trace after the turn ends).
dispatchCtx := m.restoreTraceContext(ctx, task)
m.dispatchTaskToAgent(dispatchCtx, task, teamID, *task.OwnerAgentID)
}
slog.Info("post_turn: processed pending tasks",
"team_id", teamID,
"total", len(tasks),
"dispatched", countPendingAssigned(tasks, cycled, invalidRef),
)
return nil
}
// failCycledTasks fails all tasks in the cycle and notifies the leader.
func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, cycled map[uuid.UUID]bool, taskMap map[uuid.UUID]*store.TeamTaskData) {
// Build cycle description using task identifiers.
var ids []string
for id := range cycled {
if t := taskMap[id]; t != nil {
ids = append(ids, t.Identifier)
}
}
cycleDesc := fmt.Sprintf("Circular dependency detected among tasks: %s", strings.Join(ids, " → "))
for id := range cycled {
if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil {
slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err)
}
m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: id.String(),
Status: store.TeamTaskStatusFailed,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "post_turn",
})
}
// Notify leader via system message.
m.notifyLeaderCycleError(ctx, teamID, cycleDesc)
}
// notifyLeaderCycleError sends a system message to the leader about cycled tasks.
func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uuid.UUID, cycleDesc string) {
if m.msgBus == nil {
return
}
team, err := m.teamStore.GetTeam(ctx, teamID)
if err != nil {
return
}
leadAgent, err := m.cachedGetAgentByID(ctx, team.LeadAgentID)
if err != nil {
return
}
content := fmt.Sprintf("[System] %s\nPlease recreate these tasks with corrected dependencies.", cycleDesc)
m.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: "teammate:system",
ChatID: teamID.String(),
AgentID: leadAgent.AgentKey,
UserID: team.CreatedBy,
Content: content,
Metadata: map[string]string{
"team_id": teamID.String(),
"from_agent": leadAgent.AgentKey,
"to_agent": leadAgent.AgentKey,
},
})
}
// validateBlockedBy checks blocked_by references and detects cycles using Kahn's algorithm.
// Only validates within the batch — out-of-batch blocked_by refs are assumed valid
// (already validated by executeCreate). Self-blocking is caught as invalid.
//
// Note: Cross-turn blocked_by references (tasks from previous turns) work at the DB
// level via unblockDependentTasks (WHERE $1 = ANY(blocked_by)). Cycle detection here
// only considers edges within the current batch — cross-turn deps are external.
//
// Returns: cycled task IDs, and map of taskID → first invalid blocked_by ref.
func validateBlockedBy(taskMap map[uuid.UUID]*store.TeamTaskData) (cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) {
invalidRef = make(map[uuid.UUID]uuid.UUID)
// Collect all task IDs in this batch.
batchIDs := make(map[uuid.UUID]bool, len(taskMap))
for id := range taskMap {
batchIDs[id] = true
}
// Check for self-blocking only. Out-of-batch blocked_by refs are valid
// (tasks from previous turns, already validated by executeCreate).
for id, task := range taskMap {
for _, dep := range task.BlockedBy {
if dep == id {
invalidRef[id] = dep
break
}
}
}
// Cycle detection via Kahn's algorithm.
// Only considers edges within the batch — out-of-batch deps are external
// and don't participate in cycle detection.
inDegree := make(map[uuid.UUID]int)
adj := make(map[uuid.UUID][]uuid.UUID) // blocker → dependents
for id, task := range taskMap {
if _, bad := invalidRef[id]; bad {
continue
}
if _, exists := inDegree[id]; !exists {
inDegree[id] = 0
}
for _, dep := range task.BlockedBy {
if _, bad := invalidRef[dep]; bad {
continue
}
// Only consider edges within the batch for cycle detection.
if !batchIDs[dep] {
continue
}
adj[dep] = append(adj[dep], id)
inDegree[id]++
}
}
// BFS: process nodes with in-degree 0.
var queue []uuid.UUID
for id, deg := range inDegree {
if deg == 0 {
queue = append(queue, id)
}
}
processed := make(map[uuid.UUID]bool)
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
processed[node] = true
for _, dependent := range adj[node] {
inDegree[dependent]--
if inDegree[dependent] == 0 {
queue = append(queue, dependent)
}
}
}
// Any node not processed is part of a cycle.
cycled = make(map[uuid.UUID]bool)
for id := range inDegree {
if !processed[id] {
cycled[id] = true
}
}
return cycled, invalidRef
}
// countPendingAssigned counts tasks that were eligible for dispatch.
func countPendingAssigned(tasks []*store.TeamTaskData, cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) int {
n := 0
for _, t := range tasks {
if _, c := cycled[t.ID]; c {
continue
}
if _, i := invalidRef[t.ID]; i {
continue
}
if t.Status == store.TeamTaskStatusPending && t.OwnerAgentID != nil {
n++
}
}
return n
}