mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
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:
1 parent
633872cb98
commit
e138ac7676
28 files changed
+5242
-4763
No files matched your search
+10
-444
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user