diff --git a/README.md b/README.md index 8f8e5957..7f698ae6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ GoClaw is OpenClaw, reimagined in Go. It preserves the powerful gateway architec ### Parallel Execution -- **Lane-based scheduler** — Main / subagent / cron lane isolation +- **Lane-based scheduler** — Main / subagent / delegate / cron lane isolation (env-configurable concurrency) - **Concurrent agent runs** — Multiple users in a group chat get parallel responses (configurable `maxConcurrent`, default 3 for groups). Adaptive throttle reduces concurrency when session history nears summarization threshold - **Session history isolation** — Concurrent runs buffer messages locally and flush atomically on completion, preventing cross-run context pollution - **Concurrent subagents** — Depth limits, count limits, model override @@ -98,6 +98,7 @@ Standalone mode shares everything across users (same as OpenClaw). Managed mode - **Multi-provider LLM support** — OpenRouter, Anthropic, OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity, and any OpenAI-compatible endpoint - **Agent loop** — Think-act-observe cycle with tool use, session history, and auto-summarization - **Subagents** — Spawn child agents with different models for parallel task execution +- **Agent delegation** — Delegate tasks between named agents with permission-controlled links, sync/async modes, concurrency limits, per-user restrictions, and hybrid FTS + semantic agent discovery (managed mode) - **Messaging channels** — Telegram, Discord, Zalo, Feishu/Lark, WhatsApp. `/stop` cancels the current task, `/stopall` cancels all running tasks in a chat - **Memory system** — Long-term memory with SQLite FTS5 + vector embeddings (standalone) or pgvector hybrid search (managed) - **Skills** — SKILL.md-based knowledge base with BM25 search + embedding hybrid search (managed mode) @@ -269,6 +270,15 @@ When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically | `GOCLAW_FEISHU_ENCRYPT_KEY` | Feishu message encryption key | | `GOCLAW_FEISHU_VERIFICATION_TOKEN` | Feishu verification token | +**Scheduler Lanes:** + +| Variable | Description | Default | +| ---------------------- | ---------------------------- | ------- | +| `GOCLAW_LANE_MAIN` | Main lane concurrency | `30` | +| `GOCLAW_LANE_SUBAGENT` | Subagent lane concurrency | `50` | +| `GOCLAW_LANE_DELEGATE` | Delegation lane concurrency | `100` | +| `GOCLAW_LANE_CRON` | Cron lane concurrency | `30` | + **Tailscale (requires build tag `tsnet`):** | Variable | Description | Default | @@ -492,7 +502,7 @@ POSTGRES_DB=goclaw | +-----------------------------------------------------+ | | | Tool Registry | | | | read_file|write_file|exec|web_search|web_fetch|... | | -| | memory|skill_search|tts|spawn|browser|... | | +| | memory|skill_search|tts|spawn|browser|delegate|... | | | | custom tools (runtime) | MCP bridge (stdio/SSE/HTTP) | | | +-----------------------------------------------------+ | | v | @@ -530,6 +540,8 @@ POSTGRES_DB=goclaw | `tts` | — | Text-to-Speech synthesis | | `spawn` | — | Spawn a subagent | | `subagents` | sessions | Control running subagents | +| `delegate` | — | Delegate tasks to other agents (sync/async, cancel, list) | +| `delegate_search` | — | Search delegation targets (hybrid FTS + semantic) | | `sessions_list` | sessions | List active sessions | | `sessions_history` | sessions | View session history | | `sessions_send` | sessions | Send message to a session | @@ -540,6 +552,176 @@ POSTGRES_DB=goclaw | `browser` | ui | Browser automation (navigate, click, type, screenshot) | | `canvas` | ui | Visual canvas for diagrams | +## Agent Delegation (Managed Mode) + +Agent delegation enables named agents to delegate tasks to other agents — each running with its own identity, tools, LLM provider, and context files. Unlike subagents (anonymous clones of the parent), delegation targets are fully independent agents with their own expertise. + +### How Agents Collaborate + +```mermaid +flowchart TD + USER((👤 User)) -->|"Research competitor pricing"| SUPPORT + + subgraph TEAM["🤖 Agent Team"] + SUPPORT["💬 Support Bot\n(Claude Haiku)"] + RESEARCH["🔍 Research Bot\n(GPT-4)"] + WRITER["✍️ Content Writer\n(Claude Sonnet)"] + BILLING["💳 Billing Bot\n(Gemini)"] + end + + SUPPORT -->|"🔄 sync: wait for answer"| RESEARCH + RESEARCH -->|"📋 result"| SUPPORT + SUPPORT -->|"⚡ async: don't wait"| WRITER + WRITER -.->|"📣 announce when done"| SUPPORT + SUPPORT -.-x|"🚫 no link"| BILLING + + SUPPORT -->|"✅ final answer"| USER + + style USER fill:#e1f5fe + style SUPPORT fill:#fff3e0 + style RESEARCH fill:#e8f5e9 + style WRITER fill:#f3e5f5 + style BILLING fill:#ffebee +``` + +| Mode | How it works | Best for | +|------|-------------|----------| +| **Sync** | Agent A asks Agent B and **waits** for the answer | Quick lookups, fact checks | +| **Async** | Agent A asks Agent B and **moves on**. B announces the result later | Long tasks, reports, deep analysis | + +### Architecture + +```mermaid +flowchart TD + U[User] -->|chat| A[Agent A
Customer Support
Claude Haiku] + + subgraph Delegation["Inter-Agent Delegation"] + A -->|"delegate(sync)"| B[Agent B
Research Bot
GPT-4] + A -->|"delegate(async)"| C[Agent C
Content Writer
Claude Sonnet] + B -->|result| A + C -->|announce via Message Bus| A + end + + subgraph Permission["Permission Layer"] + LINKS[(agent_links
direction · max_concurrent
settings JSONB)] + LINKS -->|outbound| B + LINKS -->|bidirectional| C + end + + subgraph Search["Agent Discovery"] + FTS[FTS
tsvector on agents] + VEC[Semantic
pgvector embeddings] + FTS & VEC -->|hybrid 0.3 + 0.7| RESULTS[delegate_search tool] + end + + A -.->|"≤15 targets"| AGENTSMD["AGENTS.md
(auto-injected)"] + A -.->|">15 targets"| RESULTS +``` + +### Permission Links + +Agents communicate through explicit **agent links** — directed edges with access control: + +```bash +# Create a one-way link: support-bot can delegate TO research-bot +agents.links.create { + "sourceAgent": "support-bot", + "targetAgent": "research-bot", + "direction": "outbound", + "description": "Allow support to request research", + "maxConcurrent": 3 +} + +# Bidirectional: both agents can delegate to each other +agents.links.create { + "sourceAgent": "support-bot", + "targetAgent": "content-writer", + "direction": "bidirectional" +} +``` + +| Direction | Meaning | +|-----------|---------| +| `outbound` | Source can delegate TO target | +| `inbound` | Target can delegate TO source | +| `bidirectional` | Both agents can delegate to each other | + +### Delegation Modes + +**Sync** (default) — Agent A blocks while B processes: + +``` +delegate(agent="research-bot", task="Find competitor pricing for X") +→ research-bot runs full agent loop (own tools, identity, provider) +→ result returned inline to agent A +``` + +**Async** — Agent A continues immediately, result announced later: + +``` +delegate(agent="research-bot", task="Deep market analysis", mode="async") +→ returns immediately with delegation ID +→ research-bot runs in background via "delegate" scheduler lane +→ result announced back through message bus → A reformulates for user +``` + +**Cancel & List:** + +``` +delegate(action="cancel", delegation_id="abc123") +delegate(action="list") +``` + +### Concurrency Control + +Two layers prevent any agent from being overwhelmed: + +| Layer | Config | Example | +|-------|--------|---------| +| **Per-link** | `agent_links.max_concurrent` | support → research: max 3 | +| **Per-agent** | `agents.other_config.max_delegation_load` | research-bot: max 5 total | + +When limits are hit, the delegate tool returns a descriptive error so the LLM can adapt (retry later, use a different agent, or handle the task itself). + +### Per-User Restrictions + +The `settings` JSONB on agent links supports per-user access control: + +```json +{ + "user_deny": ["free-tier-user"], + "user_allow": ["premium-user-1", "premium-user-2"] +} +``` + +Empty settings = all users allowed (default). Deny list is checked first, then allow list. + +### Agent Discovery + +Each agent has a `frontmatter` field — a short expertise summary used for discovery: + +- **≤15 delegation targets**: Auto-generated `AGENTS.md` is injected into the agent's context, listing all available targets with usage instructions +- **>15 targets**: Agent is instructed to use the `delegate_search` tool for hybrid FTS + semantic search (BM25 weight 0.3 + pgvector cosine weight 0.7) + +``` +delegate_search(query="billing payment refund") +→ returns ranked list of matching agents with frontmatter +→ agent picks the best match and delegates +``` + +### Key Differences from Subagents + +| Aspect | Subagents | Agent Delegation | +|--------|-----------|-----------------| +| Target | Anonymous clone of parent | Named agent with own identity | +| Provider/Model | Inherited from parent | Target's own configuration | +| Tools | Parent's tools minus deny list | Target's own tool registry + policy | +| Context files | Simplified system prompt | Target's own SOUL.md, IDENTITY.md, etc. | +| Session | Shared with parent | Isolated (fresh per delegation) | +| Permission | Depth-based limits only | Explicit `agent_links` with direction | +| User control | None | Per-user deny/allow via settings JSONB | +| Concurrency | Global + per-parent limits | Per-link + per-target-agent limits | + ## Browser Pairing Browser clients can authenticate without pre-shared tokens using a pairing code flow: @@ -638,12 +820,13 @@ GOCLAW_OPENROUTER_API_KEY=sk-or-xxx go test -v ./tests/integration/ -timeout 120 - **WebSocket RPC protocol (v3)** — Connect handshake, chat streaming, event push all tested with web dashboard and integration tests. - **Store layer (PostgreSQL)** — All PG stores (sessions, agents, providers, skills, cron, pairing, tracing, memory) implemented and running in managed mode. - **Browser automation** — Rod/CDP integration for headless Chrome, tested in production agent workflows. -- **Lane-based scheduler** — Main/subagent/cron lane isolation with concurrent execution tested. Group chats support up to 3 concurrent agent runs per session with adaptive throttle and deferred session writes for history isolation. +- **Lane-based scheduler** — Main/subagent/delegate/cron lane isolation with concurrent execution tested. Group chats support up to 3 concurrent agent runs per session with adaptive throttle and deferred session writes for history isolation. - **Security hardening** — Rate limiting, prompt injection detection, CORS, shell deny patterns, SSRF protection, credential scrubbing all implemented and verified. - **Web dashboard (core)** — Channel management, agent management, pairing approval, traces & spans viewer all implemented and working well. ### Implemented but Not Fully Tested +- **Agent delegation** — Inter-agent task delegation with permission links, sync/async modes, per-user restrictions, concurrency limits, and hybrid agent search. Core implementation complete, needs E2E testing with real multi-agent workflows. - **Other messaging channels** — Discord, Zalo, Feishu/Lark, WhatsApp channel adapters are implemented but have not been tested end-to-end in production. Only Telegram has been validated with real users. - **Skill system** — BM25 search, ZIP upload, SKILL.md parsing, and embedding hybrid search are implemented. Basic functionality verified but no full E2E flow testing with real agent usage. - **Custom tools (runtime API)** — Shell-based custom tools with JSON Schema params, encrypted env vars, and HTTP CRUD are implemented. Not yet tested in a production workflow. diff --git a/cmd/gateway.go b/cmd/gateway.go index 836621e2..483c9c87 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -640,7 +640,7 @@ func runGateway() { var instanceLoader *channels.InstanceLoader if managedStores != nil && managedStores.ChannelInstances != nil { instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore) - instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents)) + instanceLoader.RegisterFactory("telegram", telegram.FactoryWithStores(managedStores.Agents, managedStores.Teams)) instanceLoader.RegisterFactory("discord", discord.Factory) instanceLoader.RegisterFactory("feishu", feishu.Factory) instanceLoader.RegisterFactory("zalo_oa", zalo.Factory) @@ -653,7 +653,7 @@ func runGateway() { // Register config-based channels as fallback (standalone mode only). // In managed mode, channels are loaded from DB via instanceLoader — skip config-based registration. if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil { - tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil) + tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil, nil) if err != nil { slog.Error("failed to initialize telegram channel", "error", err) } else { @@ -710,6 +710,16 @@ func runGateway() { methods.NewChannelInstancesMethods(managedStores.ChannelInstances, msgBus).Register(server.Router()) } + // Register agent links WS RPC methods (managed mode only) + if managedStores != nil && managedStores.AgentLinks != nil && managedStores.Agents != nil { + methods.NewAgentLinksMethods(managedStores.AgentLinks, managedStores.Agents, agentRouter).Register(server.Router()) + } + + // Register agent teams WS RPC methods (managed mode only) + if managedStores != nil && managedStores.Teams != nil { + methods.NewTeamsMethods(managedStores.Teams, managedStores.Agents, managedStores.AgentLinks, agentRouter).Register(server.Router()) + } + // Cache invalidation: reload channel instances on changes. // Runs in a goroutine because Reload() is heavy (stops channels, waits for polling exit, // sleeps 500ms, reloads from DB, starts new channels) and Broadcast handlers must be non-blocking. diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index f885f2a3..a1f85d75 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -107,6 +107,7 @@ func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, p ContextFiles: contextFiles, EnsureUserFiles: ensureUserFiles, ContextFileLoader: contextFileLoader, + BootstrapCleanup: buildBootstrapCleanup(agentStore), CompactionCfg: cfg.Agents.Defaults.Compaction, ContextPruningCfg: cfg.Agents.Defaults.ContextPruning, SandboxEnabled: sandboxEnabled, diff --git a/cmd/gateway_callbacks.go b/cmd/gateway_callbacks.go index 2fb93122..5437d8b0 100644 --- a/cmd/gateway_callbacks.go +++ b/cmd/gateway_callbacks.go @@ -47,6 +47,15 @@ func buildEnsureUserFiles(as store.AgentStore) agent.EnsureUserFilesFunc { } } +// buildBootstrapCleanup creates a callback that removes BOOTSTRAP.md for a user. +// Used as a safety net after enough conversation turns, in case the LLM +// didn't clear BOOTSTRAP.md itself. Idempotent — no-op if already cleared. +func buildBootstrapCleanup(as store.AgentStore) agent.BootstrapCleanupFunc { + return func(ctx context.Context, agentID uuid.UUID, userID string) error { + return as.DeleteUserContextFile(ctx, agentID, userID, bootstrap.BootstrapFile) + } +} + // buildContextFileLoader creates the per-request context file loader callback. // Used by both managed and standalone modes — no mode-specific logic. // Delegates to the ContextFileInterceptor for type-aware routing. diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 75c65da2..0efa7c17 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -297,7 +297,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } // Schedule through subagent lane - outCh := sched.Schedule(ctx, "subagent", agent.RunRequest{ + outCh := sched.Schedule(ctx, scheduler.LaneSubagent, agent.RunRequest{ SessionKey: sessionKey, Message: msg.Content, Channel: origChannel, @@ -342,6 +342,146 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents continue } + // --- Delegate announce: bypass debounce, inject into parent agent session --- + // Same pattern as subagent announce above, using "delegate" lane. + if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "delegate:") { + origChannel := msg.Metadata["origin_channel"] + origPeerKind := msg.Metadata["origin_peer_kind"] + 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) + continue + } + + sessionKey := sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + + 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) + } + + outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{ + SessionKey: sessionKey, + Message: msg.Content, + Channel: origChannel, + ChatID: msg.ChatID, + PeerKind: origPeerKind, + UserID: announceUserID, + RunID: fmt.Sprintf("delegate-announce-%s", msg.Metadata["delegation_id"]), + Stream: false, + ParentTraceID: parentTraceID, + ParentRootSpanID: parentRootSpanID, + }) + + go func(origCh, chatID, senderID string) { + outcome := <-outCh + if outcome.Err != nil { + slog.Error("delegate announce: agent run failed", "error", outcome.Err) + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: origCh, + ChatID: chatID, + Content: formatAgentError(outcome.Err), + }) + return + } + if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) { + slog.Info("delegate announce: suppressed silent/empty reply", "delegation", senderID) + return + } + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: origCh, + ChatID: chatID, + Content: outcome.Result.Content, + }) + }(origChannel, msg.ChatID, msg.SenderID) + continue + } + + // --- Teammate message: bypass debounce, route to target agent session --- + // Same pattern as delegate announce, using "delegate" lane. + if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "teammate:") { + origChannel := msg.Metadata["origin_channel"] + origPeerKind := msg.Metadata["origin_peer_kind"] + 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 == "" || msg.ChatID == "" { + slog.Warn("teammate message: missing origin", "sender", msg.SenderID) + continue + } + + sessionKey := sessions.BuildScopedSessionKey(targetAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) + + slog.Info("teammate message → scheduler (delegate lane)", + "from", 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) + } + + outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{ + SessionKey: sessionKey, + Message: msg.Content, + Channel: origChannel, + ChatID: msg.ChatID, + PeerKind: origPeerKind, + UserID: announceUserID, + RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata["from_agent"], msg.Metadata["to_agent"]), + Stream: false, + }) + + go func(origCh, chatID, senderID string) { + outcome := <-outCh + if outcome.Err != nil { + slog.Error("teammate message: agent run failed", "error", outcome.Err) + return + } + if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) { + slog.Info("teammate message: suppressed silent/empty reply", "from", senderID) + return + } + // Deliver response to origin channel (same as delegate/subagent announce). + // This allows the lead to respond to users after receiving teammate updates. + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: origCh, + ChatID: chatID, + Content: outcome.Result.Content, + }) + }(origChannel, msg.ChatID, msg.SenderID) + continue + } + // --- Command: /stop — cancel oldest active run for this session --- // --- Command: /stopall — cancel ALL active runs + drain queue --- if cmd := msg.Metadata["command"]; cmd == "stop" || cmd == "stopall" { @@ -370,6 +510,28 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents 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, + }) continue } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index ee0ab5b6..49c9d84b 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -85,6 +85,7 @@ func wireManagedExtras( TraceCollector: traceCollector, EnsureUserFiles: ensureUserFiles, ContextFileLoader: contextFileLoader, + BootstrapCleanup: buildBootstrapCleanup(stores.Agents), InjectionAction: injectionAction, MaxMessageChars: appCfg.Gateway.MaxMessageChars, CompactionCfg: appCfg.Agents.Defaults.Compaction, @@ -93,6 +94,8 @@ func wireManagedExtras( SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, DynamicLoader: dynamicLoader, + AgentLinkStore: stores.AgentLinks, + TeamStore: stores.Teams, OnEvent: func(event agent.AgentEvent) { msgBus.Broadcast(bus.Event{ Name: protocol.EventAgent, @@ -234,6 +237,73 @@ func wireManagedExtras( }) } + // Register delegate tool (inter-agent delegation) if link store is available. + // Uses a callback to bridge tools.DelegateRunRequest → agent.RunRequest, + // avoiding import cycle between tools and agent packages. + if stores.AgentLinks != nil && stores.Agents != nil { + runAgentFn := func(ctx context.Context, agentKey string, req tools.DelegateRunRequest) (*tools.DelegateRunResult, error) { + loop, err := agentRouter.Get(agentKey) + if err != nil { + return nil, err + } + result, err := loop.Run(ctx, agent.RunRequest{ + SessionKey: req.SessionKey, + Message: req.Message, + UserID: req.UserID, + Channel: req.Channel, + ChatID: req.ChatID, + PeerKind: req.PeerKind, + RunID: req.RunID, + Stream: req.Stream, + ExtraSystemPrompt: req.ExtraSystemPrompt, + }) + if err != nil { + return nil, err + } + return &tools.DelegateRunResult{ + Content: result.Content, + Iterations: result.Iterations, + }, nil + } + delegateMgr := tools.NewDelegateManager(runAgentFn, stores.AgentLinks, stores.Agents, msgBus) + if stores.Teams != nil { + delegateMgr.SetTeamStore(stores.Teams) + } + delegateMgr.SetSessionStore(stores.Sessions) + toolsReg.Register(tools.NewDelegateTool(delegateMgr)) + + // Register delegate_search tool (hybrid FTS + semantic agent discovery) + var delegateEmbProvider store.EmbeddingProvider + if agentStore, ok := stores.Agents.(*pg.PGAgentStore); ok { + memCfg := appCfg.Agents.Defaults.Memory + if embProvider := resolveEmbeddingProvider(appCfg, memCfg); embProvider != nil { + agentStore.SetEmbeddingProvider(embProvider) + delegateEmbProvider = embProvider + slog.Info("managed mode: agent embeddings enabled") + + // Backfill embeddings for existing agents with frontmatter + go func() { + count, err := agentStore.BackfillAgentEmbeddings(context.Background()) + if err != nil { + slog.Warn("agent embeddings backfill failed", "error", err) + } else if count > 0 { + slog.Info("agent embeddings backfill complete", "updated", count) + } + }() + } + } + toolsReg.Register(tools.NewDelegateSearchTool(stores.AgentLinks, delegateEmbProvider)) + slog.Info("managed mode: delegate + delegate_search tools registered") + } + + // Register team tools (team_tasks + team_message) if team store is available. + if stores.Teams != nil && stores.Agents != nil { + teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus) + toolsReg.Register(tools.NewTeamTasksTool(teamMgr)) + toolsReg.Register(tools.NewTeamMessageTool(teamMgr)) + slog.Info("managed mode: team tools registered") + } + slog.Info("managed mode: resolver + interceptors + cache subscribers wired") } diff --git a/cmd/gateway_standalone.go b/cmd/gateway_standalone.go index d43c5178..296b3870 100644 --- a/cmd/gateway_standalone.go +++ b/cmd/gateway_standalone.go @@ -65,9 +65,9 @@ func wireStandaloneExtras( // Seed predefined agents' context files to disk bgCtx := context.Background() for _, e := range entries { - if e.AgentType == "predefined" { + if e.AgentType == store.AgentTypePredefined { id := file.AgentUUID(e.Key) - if _, seedErr := bootstrap.SeedToStore(bgCtx, fileStore, id, "predefined"); seedErr != nil { + if _, seedErr := bootstrap.SeedToStore(bgCtx, fileStore, id, store.AgentTypePredefined); seedErr != nil { slog.Warn("failed to seed predefined agent context files", "agent", e.Key, "error", seedErr) } } diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index fb41e388..dde129c6 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -115,13 +115,13 @@ func seedManagedData(dsn string, cfg *config.Config) error { AgentKey: "default", DisplayName: "Default Agent", OwnerID: "system", - AgentType: "open", + AgentType: store.AgentTypeOpen, Provider: defaultProvider, Model: modelID, Workspace: workspace, RestrictToWorkspace: true, IsDefault: true, - Status: "active", + Status: store.AgentStatusActive, SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`), } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0f397795..9956909f 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -23,14 +23,24 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) +// bootstrapAutoCleanupTurns is the number of user messages after which +// BOOTSTRAP.md is auto-removed if the LLM hasn't cleared it. +// Bootstrap typically completes in 2-3 conversation turns. +const bootstrapAutoCleanupTurns = 3 + // EnsureUserFilesFunc seeds per-user context files on first chat (managed mode). type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace string) error // ContextFileLoaderFunc loads context files dynamically per-request (managed mode). type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile +// BootstrapCleanupFunc removes BOOTSTRAP.md after a successful first run. +// Called automatically so the system doesn't rely on the LLM to delete it. +type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID string) error + // Loop is the agent execution loop for one agent instance. // Think → Act → Observe cycle with tool execution. type Loop struct { @@ -61,9 +71,10 @@ type Loop struct { contextFiles []bootstrap.ContextFile // Per-user file seeding + dynamic context loading (managed mode) - ensureUserFiles EnsureUserFilesFunc - contextFileLoader ContextFileLoaderFunc - seededUsers sync.Map // userID → true, avoid re-check per request + ensureUserFiles EnsureUserFilesFunc + contextFileLoader ContextFileLoaderFunc + bootstrapCleanup BootstrapCleanupFunc + seededUsers sync.Map // userID → true, avoid re-check per request // Compaction config (memory flush settings) compactionCfg *config.CompactionConfig @@ -136,6 +147,7 @@ type LoopConfig struct { // Per-user file seeding + dynamic context loading (managed mode) EnsureUserFiles EnsureUserFilesFunc ContextFileLoader ContextFileLoaderFunc + BootstrapCleanup BootstrapCleanupFunc // Tracing collector (nil = no tracing) TraceCollector *tracing.Collector @@ -189,8 +201,9 @@ func NewLoop(cfg LoopConfig) *Loop { skillAllowList: cfg.SkillAllowList, hasMemory: cfg.HasMemory, contextFiles: cfg.ContextFiles, - ensureUserFiles: cfg.EnsureUserFiles, - contextFileLoader: cfg.ContextFileLoader, + ensureUserFiles: cfg.EnsureUserFiles, + contextFileLoader: cfg.ContextFileLoader, + bootstrapCleanup: cfg.BootstrapCleanup, compactionCfg: cfg.CompactionCfg, contextPruningCfg: cfg.ContextPruningCfg, sandboxEnabled: cfg.SandboxEnabled, @@ -234,7 +247,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { l.activeRuns.Add(1) defer l.activeRuns.Add(-1) - l.emit(AgentEvent{Type: "run.started", AgentID: l.id, RunID: req.RunID}) + l.emit(AgentEvent{Type: protocol.AgentEventRunStarted, AgentID: l.id, RunID: req.RunID}) // Create trace (managed mode only) var traceID uuid.UUID @@ -261,13 +274,17 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { Channel: req.Channel, Name: "chat " + l.id, InputPreview: truncateStr(req.Message, 500), - Status: "running", + Status: store.TraceStatusRunning, StartTime: now, CreatedAt: now, } if l.agentUUID != uuid.Nil { trace.AgentID = &l.agentUUID } + // Link to parent trace if this is a delegated run + if delegateParent := tracing.DelegateParentTraceIDFromContext(ctx); delegateParent != uuid.Nil { + trace.ParentTraceID = &delegateParent + } if err := l.traceCollector.CreateTrace(ctx, trace); err != nil { slog.Warn("tracing: failed to create trace", "error", err) } else { @@ -290,7 +307,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { if err != nil { l.emit(AgentEvent{ - Type: "run.failed", + Type: protocol.AgentEventRunFailed, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{"error": err.Error()}, @@ -300,19 +317,19 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { // so the DB update still succeeds. if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { traceCtx := ctx - traceStatus := "error" + traceStatus := store.TraceStatusError if ctx.Err() != nil { traceCtx = context.Background() - traceStatus = "cancelled" + traceStatus = store.TraceStatusCancelled } l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "") } return nil, err } - l.emit(AgentEvent{Type: "run.completed", AgentID: l.id, RunID: req.RunID}) + l.emit(AgentEvent{Type: protocol.AgentEventRunCompleted, AgentID: l.id, RunID: req.RunID}) if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { - l.traceCollector.FinishTrace(ctx, traceID, "completed", "", truncateStr(result.Content, 500)) + l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, 500)) } return result, nil } @@ -409,7 +426,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) history := l.sessions.GetHistory(req.SessionKey) summary := l.sessions.GetSummary(req.SessionKey) - messages := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.UserID, req.HistoryLimit) + // buildMessages resolves context files once and also detects BOOTSTRAP.md presence + // (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection. + messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.UserID, req.HistoryLimit) // 2. Buffer new messages — write to session only AFTER the run completes. // This prevents concurrent runs from seeing each other's in-progress messages. @@ -458,7 +477,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) resp, err = l.provider.ChatStream(ctx, chatReq, func(chunk providers.StreamChunk) { if chunk.Content != "" { l.emit(AgentEvent{ - Type: "chunk", + Type: protocol.ChatEventChunk, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{"content": chunk.Content}, @@ -502,7 +521,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Single tool: sequential — no goroutine overhead tc := resp.ToolCalls[0] l.emit(AgentEvent{ - Type: "tool.call", + Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID}, @@ -529,7 +548,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } l.emit(AgentEvent{ - Type: "tool.result", + Type: protocol.AgentEventToolResult, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{ @@ -561,7 +580,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 1. Emit all tool.call events upfront (client sees all calls starting) for _, tc := range resp.ToolCalls { l.emit(AgentEvent{ - Type: "tool.call", + Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID}, @@ -615,7 +634,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } l.emit(AgentEvent{ - Type: "tool.result", + Type: protocol.AgentEventToolResult, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{ @@ -669,6 +688,26 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) l.sessions.Save(req.SessionKey) + // Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md + // as a safety net in case the LLM didn't clear it itself. + // Bootstrap typically completes in 2-3 turns; we auto-cleanup after 3 user messages. + // Uses pre-run history (already loaded) + 1 for current message — no extra DB call. + if hadBootstrap && l.bootstrapCleanup != nil { + userTurns := 1 // current user message + for _, m := range history { + if m.Role == "user" { + userTurns++ + } + } + if userTurns >= bootstrapAutoCleanupTurns { + if cleanErr := l.bootstrapCleanup(ctx, l.agentUUID, req.UserID); cleanErr != nil { + slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID) + } else { + slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns) + } + } + } + // If silent, return empty content so gateway suppresses delivery. if isSilent { slog.Info("agent loop: NO_REPLY detected, suppressing delivery", diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index b8bc72c2..5dc0aefe 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -13,7 +13,10 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" ) -func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, summary, userMessage, extraSystemPrompt, sessionKey, channel, userID string, historyLimit int) []providers.Message { +// buildMessages constructs the full message list for an LLM request. +// Returns the messages and whether BOOTSTRAP.md was present in context files +// (used by the caller for auto-cleanup without an extra DB roundtrip). +func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, summary, userMessage, extraSystemPrompt, sessionKey, channel, userID string, historyLimit int) ([]providers.Message, bool) { var messages []providers.Message // Build full system prompt using the new builder (matching TS buildAgentSystemPrompt) @@ -31,6 +34,16 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID)) } + // Resolve context files once — also detect BOOTSTRAP.md presence. + contextFiles := l.resolveContextFiles(ctx, userID) + hadBootstrap := false + for _, cf := range contextFiles { + if cf.Path == bootstrap.BootstrapFile { + hadBootstrap = true + break + } + } + systemPrompt := BuildSystemPrompt(SystemPromptConfig{ AgentID: l.id, Model: l.model, @@ -43,7 +56,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s HasMemory: l.hasMemory, HasSpawn: l.tools != nil && hasSpawn, HasSkillSearch: hasSkillSearch, - ContextFiles: l.resolveContextFiles(ctx, userID), + ContextFiles: contextFiles, ExtraPrompt: extraSystemPrompt, SandboxEnabled: l.sandboxEnabled, SandboxContainerDir: l.sandboxContainerDir, @@ -78,18 +91,37 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s Content: userMessage, }) - return messages + return messages, hadBootstrap } -// resolveContextFiles returns per-user context files if available (managed mode), -// falling back to the static contextFiles loaded at Loop creation. +// resolveContextFiles merges base context files (from resolver, e.g. auto-generated +// delegation targets) with per-user files. Per-user files override same-name base files, +// but base-only files (like auto-injected delegation info) are preserved. func (l *Loop) resolveContextFiles(ctx context.Context, userID string) []bootstrap.ContextFile { - if l.contextFileLoader != nil && userID != "" { - if files := l.contextFileLoader(ctx, l.agentUUID, userID, l.agentType); len(files) > 0 { - return files + if l.contextFileLoader == nil || userID == "" { + return l.contextFiles + } + userFiles := l.contextFileLoader(ctx, l.agentUUID, userID, l.agentType) + if len(userFiles) == 0 { + return l.contextFiles + } + if len(l.contextFiles) == 0 { + return userFiles + } + + // Merge: start with per-user files, then append base-only files + userSet := make(map[string]struct{}, len(userFiles)) + for _, f := range userFiles { + userSet[f.Path] = struct{}{} + } + merged := make([]bootstrap.ContextFile, len(userFiles)) + copy(merged, userFiles) + for _, base := range l.contextFiles { + if _, exists := userSet[base.Path]; !exists { + merged = append(merged, base) } } - return l.contextFiles + return merged } // Hybrid skill thresholds: when skill count and total token estimate are below diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 557a3782..f5d61eeb 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -43,15 +43,15 @@ func (l *Loop) emitLLMSpan(ctx context.Context, start time.Time, iteration int, dur := int(now.Sub(start).Milliseconds()) span := store.SpanData{ TraceID: traceID, - SpanType: "llm_call", + SpanType: store.SpanTypeLLMCall, Name: fmt.Sprintf("%s/%s #%d", l.provider.Name(), l.model, iteration), StartTime: start, EndTime: &now, DurationMS: dur, Model: l.model, Provider: l.provider.Name(), - Status: "completed", - Level: "DEFAULT", + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, CreatedAt: now, } if parentID := tracing.ParentSpanIDFromContext(ctx); parentID != uuid.Nil { @@ -61,15 +61,16 @@ func (l *Loop) emitLLMSpan(ctx context.Context, start time.Time, iteration int, span.AgentID = &l.agentUUID } - // Verbose mode: serialize full messages as InputPreview - if collector.Verbose() && len(messages) > 0 { + // Verbose mode: serialize full messages and output + verbose := collector.Verbose() + if verbose && len(messages) > 0 { if b, err := json.Marshal(messages); err == nil { - span.InputPreview = truncateStr(string(b), 50000) + span.InputPreview = truncateStr(string(b), 100000) } } if callErr != nil { - span.Status = "error" + span.Status = store.SpanStatusError span.Error = callErr.Error() } else if resp != nil { if resp.Usage != nil { @@ -77,7 +78,11 @@ func (l *Loop) emitLLMSpan(ctx context.Context, start time.Time, iteration int, span.OutputTokens = resp.Usage.CompletionTokens } span.FinishReason = resp.FinishReason - span.OutputPreview = truncateStr(resp.Content, 500) + if verbose { + span.OutputPreview = truncateStr(resp.Content, 100000) + } else { + span.OutputPreview = truncateStr(resp.Content, 500) + } } collector.EmitSpan(span) @@ -93,18 +98,22 @@ func (l *Loop) emitToolSpan(ctx context.Context, start time.Time, toolName, tool now := time.Now().UTC() dur := int(now.Sub(start).Milliseconds()) + previewLimit := 500 + if collector.Verbose() { + previewLimit = 100000 + } span := store.SpanData{ TraceID: traceID, - SpanType: "tool_call", + SpanType: store.SpanTypeToolCall, Name: toolName, StartTime: start, EndTime: &now, DurationMS: dur, ToolName: toolName, ToolCallID: toolCallID, - InputPreview: truncateStr(input, 500), - OutputPreview: truncateStr(output, 500), - Status: "completed", + InputPreview: truncateStr(input, previewLimit), + OutputPreview: truncateStr(output, previewLimit), + Status: store.SpanStatusCompleted, Level: "DEFAULT", CreatedAt: now, } @@ -115,7 +124,7 @@ func (l *Loop) emitToolSpan(ctx context.Context, start time.Time, toolName, tool span.AgentID = &l.agentUUID } if isError { - span.Status = "error" + span.Status = store.SpanStatusError span.Error = truncateStr(output, 200) } @@ -141,15 +150,15 @@ func (l *Loop) emitAgentSpan(ctx context.Context, start time.Time, result *RunRe span := store.SpanData{ ID: agentSpanID, TraceID: traceID, - SpanType: "agent", + SpanType: store.SpanTypeAgent, Name: spanName, StartTime: start, EndTime: &now, DurationMS: dur, Model: l.model, Provider: l.provider.Name(), - Status: "completed", - Level: "DEFAULT", + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, CreatedAt: now, } // Nest under parent root span if this is an announce run @@ -161,10 +170,14 @@ func (l *Loop) emitAgentSpan(ctx context.Context, start time.Time, result *RunRe span.AgentID = &l.agentUUID } if runErr != nil { - span.Status = "error" + span.Status = store.SpanStatusError span.Error = runErr.Error() } else if result != nil { - span.OutputPreview = truncateStr(result.Content, 500) + limit := 500 + if collector.Verbose() { + limit = 100000 + } + span.OutputPreview = truncateStr(result.Content, limit) // Note: token counts are NOT set on agent spans to avoid double-counting // with child llm_call spans. Trace aggregation sums only llm_call spans. } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index f726b17f..4ba4f1bc 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" @@ -34,6 +35,7 @@ type ResolverDeps struct { // Per-user file seeding + dynamic context loading (managed mode) EnsureUserFiles EnsureUserFilesFunc ContextFileLoader ContextFileLoaderFunc + BootstrapCleanup BootstrapCleanupFunc // Security InjectionAction string // "log", "warn", "block", "off" @@ -48,6 +50,12 @@ type ResolverDeps struct { // Dynamic custom tools (managed mode) DynamicLoader *tools.DynamicToolLoader // nil if not managed + + // Inter-agent delegation (managed mode) + AgentLinkStore store.AgentLinkStore // nil if not managed or no links + + // Agent teams (managed mode) + TeamStore store.TeamStore // nil if not managed or no teams } // NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data. @@ -88,6 +96,62 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { // Load bootstrap files from DB contextFiles := bootstrap.LoadFromStore(ctx, deps.AgentStore, ag.ID) + // Inject DELEGATION.md from delegation links (only if not already present in DB). + // Uses DELEGATION.md (not AGENTS.md) to avoid collision with per-user AGENTS.md + // which contains workspace instructions for open agents. + if deps.AgentLinkStore != nil { + hasDelegationMD := false + for _, cf := range contextFiles { + if cf.Path == bootstrap.DelegationFile { + hasDelegationMD = true + break + } + } + if !hasDelegationMD { + if allTargets, err := deps.AgentLinkStore.DelegateTargets(ctx, ag.ID); err == nil && len(allTargets) > 0 { + // Exclude auto-created team links — team members coordinate via + // team_tasks/team_message, not delegate. Only explicitly created + // links trigger DELEGATION.md. + targets := filterManualLinks(allTargets) + if len(targets) > 0 && len(targets) <= 15 { + // Static list: all targets directly + contextFiles = append(contextFiles, bootstrap.ContextFile{ + Path: bootstrap.DelegationFile, + Content: buildDelegateAgentsMD(targets), + }) + } else if len(targets) > 15 { + // Too many targets: instruct agent to use delegate_search tool + contextFiles = append(contextFiles, bootstrap.ContextFile{ + Path: bootstrap.DelegationFile, + Content: buildDelegateSearchInstruction(len(targets)), + }) + } + } + } + } + + // Inject TEAM.md for all team members (lead + members) so every agent + // knows the team workflow: create/claim/complete tasks via team_tasks tool. + if deps.TeamStore != nil { + hasTeamMD := false + for _, cf := range contextFiles { + if cf.Path == bootstrap.TeamFile { + hasTeamMD = true + break + } + } + if !hasTeamMD { + if team, err := deps.TeamStore.GetTeamForAgent(ctx, ag.ID); err == nil && team != nil { + if members, err := deps.TeamStore.ListMembers(ctx, team.ID); err == nil { + contextFiles = append(contextFiles, bootstrap.ContextFile{ + Path: bootstrap.TeamFile, + Content: buildTeamMD(team, members, ag.ID), + }) + } + } + } + } + contextWindow := ag.ContextWindow if contextWindow <= 0 { contextWindow = 200000 @@ -165,6 +229,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { ContextFiles: contextFiles, EnsureUserFiles: deps.EnsureUserFiles, ContextFileLoader: deps.ContextFileLoader, + BootstrapCleanup: deps.BootstrapCleanup, OnEvent: deps.OnEvent, TraceCollector: deps.TraceCollector, InjectionAction: deps.InjectionAction, @@ -198,3 +263,122 @@ func (r *Router) InvalidateAll() { r.agents = make(map[string]*agentEntry) slog.Debug("invalidated all agent caches") } + +// filterManualLinks removes auto-created team links from delegation targets. +// Team members coordinate via team_tasks/team_message, not delegate. +func filterManualLinks(targets []store.AgentLinkData) []store.AgentLinkData { + var filtered []store.AgentLinkData + for _, t := range targets { + if t.TeamID == nil { + filtered = append(filtered, t) + } + } + return filtered +} + +// buildDelegateAgentsMD generates DELEGATION.md content listing available delegation targets. +func buildDelegateAgentsMD(targets []store.AgentLinkData) string { + var sb strings.Builder + sb.WriteString("# Agent Delegation\n\n") + sb.WriteString("You have the `delegate` tool available. Use it to delegate tasks to other specialized agents.\n") + sb.WriteString("The agent list below is complete and authoritative — answer questions about available agents directly from it.\n") + sb.WriteString("Only use `delegate` when you need to actually assign work, not to check who is available.\n\n") + sb.WriteString("## Available Agents\n") + + for _, t := range targets { + sb.WriteString(fmt.Sprintf("\n### %s", t.TargetAgentKey)) + if t.TargetDisplayName != "" { + sb.WriteString(fmt.Sprintf(" (%s)", t.TargetDisplayName)) + } + sb.WriteString("\n") + if t.TargetDescription != "" { + sb.WriteString(t.TargetDescription + "\n") + } + sb.WriteString(fmt.Sprintf("→ `delegate(agent=\"%s\", task=\"describe the task\")`\n", t.TargetAgentKey)) + } + + sb.WriteString("\n## When to Delegate\n\n") + sb.WriteString("- The task clearly falls under another agent's expertise\n") + sb.WriteString("- You lack the tools or knowledge to handle it well\n") + sb.WriteString("- The user explicitly asks to involve another agent\n") + + return sb.String() +} + +// buildDelegateSearchInstruction generates DELEGATION.md content that instructs the agent +// to use delegate_search tool instead of listing all targets (used when >15 targets). +func buildDelegateSearchInstruction(targetCount int) string { + return fmt.Sprintf(`# Agent Delegation + +You have the `+"`delegate`"+` and `+"`delegate_search`"+` tools available. +Do NOT look for delegation info on disk — it is provided here. + +You have access to %d specialized agents. To find the right one: + +1. `+"`delegate_search(query=\"your keywords\")`"+` — search agents by expertise +2. `+"`delegate(agent=\"agent-key\", task=\"describe the task\")`"+` — delegate the task + +Example: +- User asks about billing → `+"`delegate_search(query=\"billing payment\")`"+` → `+"`delegate(agent=\"billing-agent\", task=\"...\")`"+` + +Do NOT guess agent keys. Always search first. +`, targetCount) +} + +// buildTeamMD generates compact TEAM.md content for an agent that is part of a team. +// Kept minimal — tool descriptions already live in tool Parameters()/Description(). +func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID) string { + var sb strings.Builder + sb.WriteString("# Team: " + team.Name + "\n") + if team.Description != "" { + sb.WriteString(team.Description + "\n") + } + + // Determine self role + selfRole := store.TeamRoleMember + for _, m := range members { + if m.AgentID == selfID { + selfRole = m.Role + break + } + } + sb.WriteString(fmt.Sprintf("Role: %s\n\n", selfRole)) + + // Members (including self) + sb.WriteString("## Members\n") + sb.WriteString("This is the complete and authoritative list of your team. Do NOT use tools to verify this.\n\n") + for _, m := range members { + if m.AgentID == selfID { + sb.WriteString(fmt.Sprintf("- **you** (%s)", m.Role)) + } else { + sb.WriteString(fmt.Sprintf("- **%s** (%s)", m.AgentKey, m.Role)) + } + if m.Frontmatter != "" { + sb.WriteString(": " + m.Frontmatter) + } + sb.WriteString("\n") + } + + // Workflow guidance + sb.WriteString("\n## Workflow\n\n") + if selfRole == store.TeamRoleLead { + sb.WriteString("**MANDATORY**: ALWAYS use `team_tasks` to track work. NEVER call `delegate` without a task.\n\n") + sb.WriteString("Every delegation MUST follow these 2 steps:\n") + sb.WriteString("1. `team_tasks` action=create, subject= → returns task_id\n") + sb.WriteString("2. `delegate` agent=, task=, team_task_id=\n\n") + sb.WriteString("The task auto-completes when delegation finishes. This ensures all work is tracked.\n\n") + sb.WriteString("Other `team_tasks` actions:\n") + sb.WriteString("- action=list → view all team tasks and their status\n") + sb.WriteString("- action=complete, task_id=, result= → manually complete a task\n\n") + sb.WriteString("Use `team_message` to send updates to team members.\n\n") + sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") + } else { + sb.WriteString("As a member, when you receive a delegated task, just do the work.\n") + sb.WriteString("Task completion is handled automatically by the system.\n\n") + sb.WriteString("You can use `team_tasks` action=list to check the team's task board.\n") + sb.WriteString("Use `team_message` to send updates to your team lead.\n\n") + sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") + } + + return sb.String() +} diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index ca4ff79d..69fb8e90 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -356,10 +356,10 @@ func buildProjectContextSection(files []bootstrap.ContextFile) []string { hasBootstrap := false for _, f := range files { base := filepath.Base(f.Path) - if strings.EqualFold(base, "soul.md") { + if strings.EqualFold(base, bootstrap.SoulFile) { hasSoul = true } - if strings.EqualFold(base, "bootstrap.md") { + if strings.EqualFold(base, bootstrap.BootstrapFile) { hasBootstrap = true } } @@ -389,6 +389,25 @@ func buildProjectContextSection(files []bootstrap.ContextFile) []string { for _, f := range files { base := filepath.Base(f.Path) + + // During bootstrap (first run), skip delegation/team files — they add noise + // and waste tokens when the agent should only be introducing itself. + if hasBootstrap && (base == bootstrap.DelegationFile || base == bootstrap.TeamFile) { + continue + } + + // Virtual files (DELEGATION.md, TEAM.md) are system-injected, not on disk. + // Render with so the LLM doesn't try to read/write them as files. + if base == bootstrap.DelegationFile || base == bootstrap.TeamFile { + lines = append(lines, + fmt.Sprintf("", base), + f.Content, + "", + "", + ) + continue + } + lines = append(lines, fmt.Sprintf("## %s", f.Path), fmt.Sprintf("", base), @@ -472,7 +491,7 @@ func buildRuntimeSection(cfg SystemPromptConfig) []string { // hasBootstrapFile checks if BOOTSTRAP.md is present in the context files. func hasBootstrapFile(files []bootstrap.ContextFile) bool { for _, f := range files { - if strings.EqualFold(filepath.Base(f.Path), "bootstrap.md") { + if strings.EqualFold(filepath.Base(f.Path), bootstrap.BootstrapFile) { return true } } diff --git a/internal/bootstrap/files.go b/internal/bootstrap/files.go index ec9a22bc..b862e47d 100644 --- a/internal/bootstrap/files.go +++ b/internal/bootstrap/files.go @@ -28,8 +28,11 @@ const ( UserFile = "USER.md" HeartbeatFile = "HEARTBEAT.md" BootstrapFile = "BOOTSTRAP.md" - MemoryFile = "MEMORY.md" - MemoryAltFile = "memory.md" + DelegationFile = "DELEGATION.md" + TeamFile = "TEAM.md" + MemoryFile = "MEMORY.md" + MemoryAltFile = "memory.md" + MemoryJSONFile = "MEMORY.json" ) // standardFiles is the ordered list of bootstrap files to load. diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index cdb68172..e21ae16a 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -17,7 +17,7 @@ import ( func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, agentType string) ([]string, error) { // Open agents don't need agent-level context files — // all files are seeded per-user from embedded templates on first chat. - if agentType == "open" { + if agentType == store.AgentTypeOpen { return nil, nil } @@ -89,7 +89,7 @@ var userSeedFilesPredefined = []string{ // Returns the list of file names that were seeded. func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) { files := userSeedFilesOpen - if agentType == "predefined" { + if agentType == store.AgentTypePredefined { files = userSeedFilesPredefined } @@ -113,7 +113,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui // Predefined agents use a user-focused bootstrap template templateName := name - if agentType == "predefined" && name == BootstrapFile { + if agentType == store.AgentTypePredefined && name == BootstrapFile { templateName = "BOOTSTRAP_PREDEFINED.md" } diff --git a/internal/bus/types.go b/internal/bus/types.go index 6f5f32ee..c09e4156 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -39,10 +39,20 @@ type Event struct { Payload interface{} `json:"payload,omitempty"` } +// Cache invalidation kind constants. +const ( + CacheKindAgent = "agent" + CacheKindBootstrap = "bootstrap" + CacheKindSkills = "skills" + CacheKindCron = "cron" + CacheKindCustomTools = "custom_tools" + CacheKindChannelInstances = "channel_instances" +) + // CacheInvalidatePayload signals cache layers to evict stale entries. // Used with protocol.EventCacheInvalidate events. type CacheInvalidatePayload struct { - Kind string `json:"kind"` // "agent", "bootstrap", "skills", "cron", "custom_tools", "channel_instances" + Kind string `json:"kind"` // CacheKind* constants Key string `json:"key"` // agent_key, agent_id, etc. Empty = invalidate all } diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 05da25b0..5e530c20 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // RunContext tracks an active agent run for streaming/reaction event forwarding. @@ -256,11 +257,11 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) // Forward to StreamingChannel if sc, ok := ch.(StreamingChannel); ok { switch eventType { - case "run.started": + case protocol.AgentEventRunStarted: if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil { slog.Debug("stream start failed", "channel", rc.ChannelName, "error", err) } - case "tool.call": + case protocol.AgentEventToolCall: // Agent is executing a tool — mark tool phase so the next chunk // (new LLM iteration) resets the stream buffer. // Also clear the current DraftStream so the next iteration starts @@ -271,7 +272,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) if err := sc.OnStreamEnd(ctx, rc.ChatID, ""); err != nil { slog.Debug("stream tool-phase end failed", "channel", rc.ChannelName, "error", err) } - case "chunk": + case protocol.ChatEventChunk: // Accumulate chunk deltas into full text. // When entering a new LLM iteration (first chunk after tool.call), // reset the buffer so we don't concatenate text from previous iterations. @@ -296,14 +297,14 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) slog.Debug("stream chunk failed", "channel", rc.ChannelName, "error", err) } } - case "run.completed": + case protocol.AgentEventRunCompleted: rc.mu.Lock() finalText := rc.streamBuffer rc.mu.Unlock() if err := sc.OnStreamEnd(ctx, rc.ChatID, finalText); err != nil { slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err) } - case "run.failed": + case protocol.AgentEventRunFailed: // Clean up streaming state _ = sc.OnStreamEnd(ctx, rc.ChatID, "") } @@ -313,13 +314,13 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) if reactionCh, ok := ch.(ReactionChannel); ok { status := "" switch eventType { - case "run.started": + case protocol.AgentEventRunStarted: status = "thinking" - case "tool.call": + case protocol.AgentEventToolCall: status = "tool" - case "run.completed": + case protocol.AgentEventRunCompleted: status = "done" - case "run.failed": + case protocol.AgentEventRunFailed: status = "error" } if status != "" { @@ -330,7 +331,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) } // Clean up on terminal events - if eventType == "run.completed" || eventType == "run.failed" { + if eventType == protocol.AgentEventRunCompleted || eventType == protocol.AgentEventRunFailed { m.runs.Delete(runID) } } diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index ebbbc44d..4753182f 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -25,6 +25,7 @@ type Channel struct { config config.TelegramConfig pairingService store.PairingStore agentStore store.AgentStore // for group file writer management (nil in standalone) + teamStore store.TeamStore // for /tasks, /task_detail commands (nil in standalone) placeholders sync.Map // localKey string → messageID int stopThinking sync.Map // localKey string → *thinkingCancel streams sync.Map // localKey string → *DraftStream (streaming preview) @@ -52,7 +53,8 @@ func (c *thinkingCancel) Cancel() { // New creates a new Telegram channel from config. // pairingSvc is optional (nil = fall back to allowlist only). // agentStore is optional (nil = group file writer commands disabled). -func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (*Channel, error) { +// teamStore is optional (nil = /tasks, /task_detail commands disabled). +func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore) (*Channel, error) { var opts []telego.BotOption if cfg.Proxy != "" { @@ -90,6 +92,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai config: cfg, pairingService: pairingSvc, agentStore: agentStore, + teamStore: teamStore, groupHistory: channels.NewPendingHistory(), historyLimit: historyLimit, requireMention: requireMention, @@ -156,6 +159,8 @@ func (c *Channel) Start(ctx context.Context) error { } if update.Message != nil { c.handleMessage(pollCtx, update) + } else if update.CallbackQuery != nil { + c.handleCallbackQuery(pollCtx, update.CallbackQuery) } else { // Log non-message updates for delivery diagnostics updateType := "unknown" @@ -164,8 +169,6 @@ func (c *Channel) Start(ctx context.Context) error { updateType = "edited_message" case update.ChannelPost != nil: updateType = "channel_post" - case update.CallbackQuery != nil: - updateType = "callback_query" case update.MyChatMember != nil: updateType = "my_chat_member" case update.ChatMember != nil: diff --git a/internal/channels/telegram/commands.go b/internal/channels/telegram/commands.go index 82423c71..22c7bd73 100644 --- a/internal/channels/telegram/commands.go +++ b/internal/channels/telegram/commands.go @@ -12,6 +12,7 @@ import ( tu "github.com/mymmrac/telego/telegoutil" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // resolveAgentUUID looks up the agent UUID from the channel's agent key. @@ -71,6 +72,8 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, "/stopall — Stop all running tasks\n" + "/reset — Reset conversation history\n" + "/status — Show bot status\n" + + "/tasks — List team tasks\n" + + "/task_detail — View task detail\n" + "/writers — List file writers for this group\n" + "/addwriter — Add a file writer (reply to their message)\n" + "/removewriter — Remove a file writer (reply to their message)\n" + @@ -126,9 +129,7 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, "message_thread_id": fmt.Sprintf("%d", messageThreadID), }, }) - msg := tu.Message(chatIDObj, "Stopping current task…") - setThread(msg) - c.bot.SendMessage(ctx, msg) + // Feedback is sent by the consumer after cancel result is known. return true case "/stopall": @@ -151,9 +152,7 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, "message_thread_id": fmt.Sprintf("%d", messageThreadID), }, }) - msg := tu.Message(chatIDObj, "Stopping all tasks…") - setThread(msg) - c.bot.SendMessage(ctx, msg) + // Feedback is sent by the consumer after cancel result is known. return true case "/status": @@ -163,6 +162,14 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, c.bot.SendMessage(ctx, msg) return true + case "/tasks": + c.handleTasksList(ctx, chatID, setThread) + return true + + case "/task_detail": + c.handleTaskDetail(ctx, chatID, text, setThread) + return true + case "/addwriter": c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "add") return true @@ -319,6 +326,263 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr send(sb.String()) } +// --- Team tasks --- + +const maxTasksInList = 30 + +// taskStatusIcon returns a short icon for each task status. +func taskStatusIcon(status string) string { + switch status { + case "completed": + return "✅" + case "in_progress": + return "🔄" + case "blocked": + return "⛔" + default: // pending + return "⏳" + } +} + +// truncateStr truncates a string to maxLen runes, appending "…" if truncated. +func truncateStr(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} + +// handleTasksList handles the /tasks command — lists team tasks. +func (c *Channel) handleTasksList(ctx context.Context, chatID int64, setThread func(*telego.SendMessageParams)) { + chatIDObj := tu.ID(chatID) + + send := func(text string) { + msg := tu.Message(chatIDObj, text) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + + if c.teamStore == nil { + send("Team features are not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("tasks command: agent resolve failed", "error", err) + send("Team features are not available (no agent).") + return + } + + team, err := c.teamStore.GetTeamForAgent(ctx, agentID) + if err != nil { + slog.Warn("tasks command: GetTeamForAgent failed", "error", err) + send("Failed to look up team. Please try again.") + return + } + if team == nil { + send("This agent is not part of any team.") + return + } + + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest") + if err != nil { + slog.Warn("tasks command: ListTasks failed", "error", err) + send("Failed to list tasks. Please try again.") + return + } + + if len(tasks) == 0 { + send(fmt.Sprintf("No tasks for team %q.", team.Name)) + return + } + + total := len(tasks) + if total > maxTasksInList { + tasks = tasks[:maxTasksInList] + } + + var sb strings.Builder + if total > maxTasksInList { + sb.WriteString(fmt.Sprintf("Tasks for team %q (showing %d of %d):\n\n", team.Name, maxTasksInList, total)) + } else { + sb.WriteString(fmt.Sprintf("Tasks for team %q (%d):\n\n", team.Name, total)) + } + for i, t := range tasks { + owner := "" + if t.OwnerAgentKey != "" { + owner = " — @" + t.OwnerAgentKey + } + sb.WriteString(fmt.Sprintf("%d. %s %s%s\n", i+1, taskStatusIcon(t.Status), t.Subject, owner)) + } + sb.WriteString("\nTap a button below to view details.") + + // Build inline keyboard — one button per task. + var rows [][]telego.InlineKeyboardButton + for i, t := range tasks { + label := fmt.Sprintf("%d. %s %s", i+1, taskStatusIcon(t.Status), truncateStr(t.Subject, 35)) + rows = append(rows, []telego.InlineKeyboardButton{ + {Text: label, CallbackData: "td:" + t.ID.String()}, + }) + } + + msg := tu.Message(chatIDObj, sb.String()) + setThread(msg) + if len(rows) > 0 { + msg.ReplyMarkup = &telego.InlineKeyboardMarkup{InlineKeyboard: rows} + } + c.bot.SendMessage(ctx, msg) +} + +// handleTaskDetail handles the /task_detail command — shows detail for a task. +func (c *Channel) handleTaskDetail(ctx context.Context, chatID int64, text string, setThread func(*telego.SendMessageParams)) { + chatIDObj := tu.ID(chatID) + + send := func(t string) { + for _, chunk := range chunkPlainText(t, telegramMaxMessageLen) { + msg := tu.Message(chatIDObj, chunk) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + } + + // Extract task ID argument: "/task_detail " or "/task_detail@botname " + parts := strings.SplitN(text, " ", 2) + if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { + send("Usage: /task_detail ") + return + } + taskIDArg := strings.TrimSpace(parts[1]) + + if c.teamStore == nil { + send("Team features are not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("task_detail command: agent resolve failed", "error", err) + send("Team features are not available (no agent).") + return + } + + team, err := c.teamStore.GetTeamForAgent(ctx, agentID) + if err != nil { + slog.Warn("task_detail command: GetTeamForAgent failed", "error", err) + send("Failed to look up team. Please try again.") + return + } + if team == nil { + send("This agent is not part of any team.") + return + } + + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest") + if err != nil { + slog.Warn("task_detail command: ListTasks failed", "error", err) + send("Failed to list tasks. Please try again.") + return + } + + // Find task by full UUID or prefix match. + taskIDLower := strings.ToLower(taskIDArg) + for i := range tasks { + tid := tasks[i].ID.String() + if tid == taskIDLower || strings.HasPrefix(tid, taskIDLower) { + send(formatTaskDetail(&tasks[i])) + return + } + } + + send(fmt.Sprintf("Task %q not found. Use /tasks to see available tasks.", taskIDArg)) +} + +// handleCallbackQuery handles inline keyboard button presses. +func (c *Channel) handleCallbackQuery(ctx context.Context, query *telego.CallbackQuery) { + // Always answer to dismiss the loading indicator. + c.bot.AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{ + CallbackQueryID: query.ID, + }) + + if !strings.HasPrefix(query.Data, "td:") { + return + } + + taskIDStr := strings.TrimPrefix(query.Data, "td:") + + // Resolve chat ID from the callback's message. + chatID := query.Message.GetChat().ID + chatIDObj := tu.ID(chatID) + + send := func(text string) { + for _, chunk := range chunkPlainText(text, telegramMaxMessageLen) { + msg := tu.Message(chatIDObj, chunk) + c.bot.SendMessage(ctx, msg) + } + } + + if c.teamStore == nil { + send("Team features are not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + send("Team features are not available (no agent).") + return + } + + team, err := c.teamStore.GetTeamForAgent(ctx, agentID) + if err != nil || team == nil { + send("Could not resolve team.") + return + } + + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest") + if err != nil { + send("Failed to list tasks.") + return + } + + for i := range tasks { + if tasks[i].ID.String() == taskIDStr { + send(formatTaskDetail(&tasks[i])) + return + } + } + send(fmt.Sprintf("Task %s not found.", taskIDStr[:8])) +} + +// formatTaskDetail formats a single task for display. +func formatTaskDetail(t *store.TeamTaskData) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Task: %s\n", t.Subject)) + sb.WriteString(fmt.Sprintf("ID: %s\n", t.ID.String())) + sb.WriteString(fmt.Sprintf("Status: %s %s\n", taskStatusIcon(t.Status), t.Status)) + if t.OwnerAgentKey != "" { + sb.WriteString(fmt.Sprintf("Owner: @%s\n", t.OwnerAgentKey)) + } + sb.WriteString(fmt.Sprintf("Priority: %d\n", t.Priority)) + if !t.CreatedAt.IsZero() { + sb.WriteString(fmt.Sprintf("Created: %s\n", t.CreatedAt.Format("2006-01-02 15:04"))) + } + if t.Description != "" { + sb.WriteString(fmt.Sprintf("\nDescription:\n%s\n", t.Description)) + } + if t.Result != nil && *t.Result != "" { + sb.WriteString(fmt.Sprintf("\nResult:\n%s\n", *t.Result)) + } + if len(t.BlockedBy) > 0 { + ids := make([]string, len(t.BlockedBy)) + for j, bid := range t.BlockedBy { + ids[j] = bid.String()[:8] + } + sb.WriteString(fmt.Sprintf("\nBlocked by: %s\n", strings.Join(ids, ", "))) + } + return sb.String() +} + // --- Pairing UX --- // buildPairingReply builds the pairing reply message matching TS behavior. @@ -432,6 +696,8 @@ func DefaultMenuCommands() []telego.BotCommand { {Command: "stopall", Description: "Stop all running tasks"}, {Command: "reset", Description: "Reset conversation history"}, {Command: "status", Description: "Show bot status"}, + {Command: "tasks", Description: "List team tasks"}, + {Command: "task_detail", Description: "View task detail by ID"}, {Command: "writers", Description: "List file writers for this group"}, {Command: "addwriter", Description: "Add a file writer (reply to their message)"}, {Command: "removewriter", Description: "Remove a file writer (reply to their message)"}, diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index 01903b6a..a8b56985 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -29,23 +29,23 @@ type telegramInstanceConfig struct { AllowFrom []string `json:"allow_from,omitempty"` } -// Factory creates a Telegram channel from DB instance data (no agent store = no group file writer commands). +// Factory creates a Telegram channel from DB instance data (no agent/team store). func Factory(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil) + return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil) } -// FactoryWithAgentStore returns a ChannelFactory that includes the agent store -// for group file writer management (/addwriter, /removewriter, /writers commands). -func FactoryWithAgentStore(agentStore store.AgentStore) channels.ChannelFactory { +// FactoryWithStores returns a ChannelFactory that includes agent and team stores +// for group file writer management and /tasks, /task_detail commands. +func FactoryWithStores(agentStore store.AgentStore, teamStore store.TeamStore) channels.ChannelFactory { return func(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore) + return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, teamStore) } } func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, - msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (channels.Channel, error) { + msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore) (channels.Channel, error) { var c telegramCreds if len(creds) > 0 { @@ -85,7 +85,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, tgCfg.GroupPolicy = "pairing" } - ch, err := New(tgCfg, msgBus, pairingSvc, agentStore) + ch, err := New(tgCfg, msgBus, pairingSvc, agentStore, teamStore) if err != nil { return nil, err } diff --git a/internal/channels/telegram/format.go b/internal/channels/telegram/format.go index 7ece7ca9..f1610571 100644 --- a/internal/channels/telegram/format.go +++ b/internal/channels/telegram/format.go @@ -317,6 +317,12 @@ func displayWidth(s string) int { // chunkHTML splits HTML text into chunks that fit within maxLen. // Prefers splitting at paragraph boundaries (\n\n), then line boundaries (\n), // then word boundaries (space). Matching TS chunkText() logic. +// chunkPlainText splits plain text into chunks that fit within maxLen, +// preferring to split at paragraph or line boundaries. +func chunkPlainText(text string, maxLen int) []string { + return chunkHTML(text, maxLen) +} + func chunkHTML(text string, maxLen int) []string { if len(text) <= maxLen { return []string{text} diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go new file mode 100644 index 00000000..ea0676e4 --- /dev/null +++ b/internal/gateway/methods/agent_links.go @@ -0,0 +1,357 @@ +package methods + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// AgentLinksMethods handles agents.links.* RPC methods. +type AgentLinksMethods struct { + linkStore store.AgentLinkStore + agentStore store.AgentStore + agentRouter *agent.Router // for cache invalidation when links change +} + +func NewAgentLinksMethods(linkStore store.AgentLinkStore, agentStore store.AgentStore, agentRouter *agent.Router) *AgentLinksMethods { + return &AgentLinksMethods{linkStore: linkStore, agentStore: agentStore, agentRouter: agentRouter} +} + +func (m *AgentLinksMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodAgentsLinksList, m.handleList) + router.Register(protocol.MethodAgentsLinksCreate, m.handleCreate) + router.Register(protocol.MethodAgentsLinksUpdate, m.handleUpdate) + router.Register(protocol.MethodAgentsLinksDelete, m.handleDelete) +} + +// --- List --- + +type linksListParams struct { + AgentID string `json:"agentId"` + Direction string `json:"direction"` // "from" (default), "to", "all" +} + +func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.linkStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + return + } + + var params linksListParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required")) + return + } + + agentID, err := resolveAgentUUID(m.agentStore, params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) + return + } + + ctx := context.Background() + var links []store.AgentLinkData + + switch params.Direction { + case "to": + links, err = m.linkStore.ListLinksTo(ctx, agentID) + case "all": + from, errFrom := m.linkStore.ListLinksFrom(ctx, agentID) + to, errTo := m.linkStore.ListLinksTo(ctx, agentID) + if errFrom != nil { + err = errFrom + } else if errTo != nil { + err = errTo + } else { + links = append(from, to...) + } + default: // "from" + links, err = m.linkStore.ListLinksFrom(ctx, agentID) + } + + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "links": links, + "count": len(links), + })) +} + +// --- Create --- + +type linksCreateParams struct { + SourceAgent string `json:"sourceAgent"` + TargetAgent string `json:"targetAgent"` + Direction string `json:"direction"` + Description string `json:"description"` + MaxConcurrent int `json:"maxConcurrent"` + Settings json.RawMessage `json:"settings"` +} + +func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.linkStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + return + } + + var params linksCreateParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.SourceAgent == "" || params.TargetAgent == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sourceAgent and targetAgent are required")) + return + } + + direction := params.Direction + if direction == "" { + direction = store.LinkDirectionOutbound + } + if direction != store.LinkDirectionOutbound && direction != store.LinkDirectionInbound && direction != store.LinkDirectionBidirectional { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional")) + return + } + + sourceAgent, err := resolveAgentInfo(m.agentStore, params.SourceAgent) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source agent: "+err.Error())) + return + } + targetAgent, err := resolveAgentInfo(m.agentStore, params.TargetAgent) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "target agent: "+err.Error())) + return + } + + if sourceAgent.ID == targetAgent.ID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source and target must be different agents")) + return + } + + // Delegation targets must be predefined agents (open agents have no agent-level context files) + if targetAgent.AgentType == store.AgentTypeOpen { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delegate to open agents — only predefined agents can be delegation targets")) + return + } + + maxConcurrent := params.MaxConcurrent + if maxConcurrent <= 0 { + maxConcurrent = 3 + } + + link := &store.AgentLinkData{ + SourceAgentID: sourceAgent.ID, + TargetAgentID: targetAgent.ID, + Direction: direction, + Description: params.Description, + MaxConcurrent: maxConcurrent, + Settings: params.Settings, + Status: store.LinkStatusActive, + CreatedBy: client.UserID(), + } + + ctx := context.Background() + if err := m.linkStore.CreateLink(ctx, link); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create link: "+err.Error())) + return + } + + // Invalidate agent cache so DELEGATION.md gets regenerated with new link + if m.agentRouter != nil { + m.agentRouter.InvalidateAgent(sourceAgent.AgentKey) + m.agentRouter.InvalidateAgent(targetAgent.AgentKey) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "link": link, + })) +} + +// --- Update --- + +type linksUpdateParams struct { + LinkID string `json:"linkId"` + Direction string `json:"direction"` + Description *string `json:"description"` + MaxConcurrent *int `json:"maxConcurrent"` + Settings json.RawMessage `json:"settings"` + Status string `json:"status"` +} + +func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.linkStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + return + } + + var params linksUpdateParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.LinkID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required")) + return + } + + linkID, err := uuid.Parse(params.LinkID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId")) + return + } + + updates := map[string]any{} + if params.Direction != "" { + if params.Direction != store.LinkDirectionOutbound && params.Direction != store.LinkDirectionInbound && params.Direction != store.LinkDirectionBidirectional { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional")) + return + } + updates["direction"] = params.Direction + } + if params.Description != nil { + updates["description"] = *params.Description + } + if params.MaxConcurrent != nil && *params.MaxConcurrent > 0 { + updates["max_concurrent"] = *params.MaxConcurrent + } + if len(params.Settings) > 0 { + updates["settings"] = []byte(params.Settings) + } + if params.Status != "" { + if params.Status != store.LinkStatusActive && params.Status != store.LinkStatusDisabled { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "status must be active or disabled")) + return + } + updates["status"] = params.Status + } + + if len(updates) == 0 { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no updates provided")) + return + } + + ctx := context.Background() + if err := m.linkStore.UpdateLink(ctx, linkID, updates); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update link: "+err.Error())) + return + } + + // Invalidate affected agents so AGENTS.md gets regenerated + m.invalidateLinkAgents(ctx, linkID) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Delete --- + +type linksDeleteParams struct { + LinkID string `json:"linkId"` +} + +func (m *AgentLinksMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.linkStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + return + } + + var params linksDeleteParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.LinkID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required")) + return + } + + linkID, err := uuid.Parse(params.LinkID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId")) + return + } + + ctx := context.Background() + + // Fetch link before deleting to get agent IDs for cache invalidation + link, _ := m.linkStore.GetLink(ctx, linkID) + + if err := m.linkStore.DeleteLink(ctx, linkID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete link: "+err.Error())) + return + } + + // Invalidate affected agents so AGENTS.md gets regenerated + if link != nil { + m.invalidateLinkAgentsByID(ctx, link.SourceAgentID, link.TargetAgentID) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// invalidateLinkAgents fetches a link by ID and invalidates both source and target agent caches. +func (m *AgentLinksMethods) invalidateLinkAgents(ctx context.Context, linkID uuid.UUID) { + if m.agentRouter == nil { + return + } + link, err := m.linkStore.GetLink(ctx, linkID) + if err != nil || link == nil { + return + } + m.invalidateLinkAgentsByID(ctx, link.SourceAgentID, link.TargetAgentID) +} + +// invalidateLinkAgentsByID invalidates agent caches by looking up agent keys from UUIDs. +func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, sourceID, targetID uuid.UUID) { + if m.agentRouter == nil { + return + } + if src, err := m.agentStore.GetByID(ctx, sourceID); err == nil { + m.agentRouter.InvalidateAgent(src.AgentKey) + } + if tgt, err := m.agentStore.GetByID(ctx, targetID); err == nil { + m.agentRouter.InvalidateAgent(tgt.AgentKey) + } +} + +// --- helpers --- + +func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { + if id, err := uuid.Parse(keyOrID); err == nil { + ag, err := agentStore.GetByID(context.Background(), id) + if err != nil { + return uuid.Nil, err + } + return ag.ID, nil + } + ag, err := agentStore.GetByKey(context.Background(), keyOrID) + if err != nil { + return uuid.Nil, err + } + return ag.ID, nil +} + +// resolveAgentInfo returns full agent data for validation and cache invalidation. +func resolveAgentInfo(agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) { + if id, err := uuid.Parse(keyOrID); err == nil { + return agentStore.GetByID(context.Background(), id) + } + return agentStore.GetByKey(context.Background(), keyOrID) +} diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index 7e1db413..f5000b17 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -130,7 +130,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, agentType := params.AgentType if agentType == "" { - agentType = "open" + agentType = store.AgentTypeOpen } agentID := config.NormalizeAgentID(params.Name) @@ -165,7 +165,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, Provider: m.cfg.Agents.Defaults.Provider, Model: m.cfg.Agents.Defaults.Model, Workspace: ws, - Status: "active", + Status: store.AgentStatusActive, ToolsConfig: params.ToolsConfig, SubagentsConfig: params.SubagentsConfig, SandboxConfig: params.SandboxConfig, @@ -471,8 +471,8 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, // allowedAgentFiles is the list of files exposed via agents.files.* RPCs. var allowedAgentFiles = []string{ - "AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", - "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md", "MEMORY.json", + bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.ToolsFile, bootstrap.IdentityFile, + bootstrap.UserFile, bootstrap.HeartbeatFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile, } func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go index 25bfa2cf..4203f9e9 100644 --- a/internal/gateway/methods/channel_instances.go +++ b/internal/gateway/methods/channel_instances.go @@ -39,7 +39,7 @@ func (m *ChannelInstancesMethods) emitCacheInvalidate() { } m.msgBus.Broadcast(bus.Event{ Name: protocol.EventCacheInvalidate, - Payload: bus.CacheInvalidatePayload{Kind: "channel_instances"}, + Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances}, }) } diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go new file mode 100644 index 00000000..7fca272d --- /dev/null +++ b/internal/gateway/methods/teams.go @@ -0,0 +1,320 @@ +package methods + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// TeamsMethods handles teams.* RPC methods. +type TeamsMethods struct { + teamStore store.TeamStore + agentStore store.AgentStore + linkStore store.AgentLinkStore // for auto-creating bidirectional links + agentRouter *agent.Router // for cache invalidation +} + +func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router) *TeamsMethods { + return &TeamsMethods{teamStore: teamStore, agentStore: agentStore, linkStore: linkStore, agentRouter: agentRouter} +} + +func (m *TeamsMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodTeamsList, m.handleList) + router.Register(protocol.MethodTeamsCreate, m.handleCreate) + router.Register(protocol.MethodTeamsGet, m.handleGet) + router.Register(protocol.MethodTeamsDelete, m.handleDelete) + router.Register(protocol.MethodTeamsTaskList, m.handleTaskList) +} + +// --- List --- + +func (m *TeamsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + ctx := context.Background() + teams, err := m.teamStore.ListTeams(ctx) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "teams": teams, + "count": len(teams), + })) +} + +// --- Create --- + +type teamsCreateParams struct { + Name string `json:"name"` + Lead string `json:"lead"` // agent key or UUID + Members []string `json:"members"` // agent keys or UUIDs + Description string `json:"description"` + Settings json.RawMessage `json:"settings"` +} + +func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsCreateParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.Name == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required")) + return + } + if params.Lead == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead is required")) + return + } + + // Resolve lead agent + leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error())) + return + } + + // Resolve member agents + var memberAgents []*store.AgentData + for _, memberKey := range params.Members { + ag, err := resolveAgentInfo(m.agentStore, memberKey) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error())) + return + } + memberAgents = append(memberAgents, ag) + } + + ctx := context.Background() + + // Create team + team := &store.TeamData{ + Name: params.Name, + LeadAgentID: leadAgent.ID, + Description: params.Description, + Status: store.TeamStatusActive, + Settings: params.Settings, + CreatedBy: client.UserID(), + } + if err := m.teamStore.CreateTeam(ctx, team); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create team: "+err.Error())) + return + } + + // Add lead as member with lead role + if err := m.teamStore.AddMember(ctx, team.ID, leadAgent.ID, store.TeamRoleLead); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add lead as member: "+err.Error())) + return + } + + // Add members + for _, ag := range memberAgents { + if ag.ID == leadAgent.ID { + continue // lead already added + } + if err := m.teamStore.AddMember(ctx, team.ID, ag.ID, store.TeamRoleMember); err != nil { + slog.Warn("teams.create: failed to add member", "agent", ag.AgentKey, "error", err) + } + } + + // Auto-create bidirectional agent_links between all team members. + // This enables delegation between teammates. + if m.linkStore != nil { + allAgents := append([]*store.AgentData{leadAgent}, memberAgents...) + m.autoCreateTeamLinks(ctx, team.ID, allAgents, client.UserID()) + } + + // Invalidate agent caches so TEAM.md gets injected + if m.agentRouter != nil { + m.agentRouter.InvalidateAgent(leadAgent.AgentKey) + for _, ag := range memberAgents { + m.agentRouter.InvalidateAgent(ag.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "team": team, + })) +} + +// --- Get --- + +type teamsGetParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsGetParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + members, err := m.teamStore.ListMembers(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "team": team, + "members": members, + })) +} + +// --- Delete --- + +type teamsDeleteParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsDeleteParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Fetch members before deleting for cache invalidation + members, _ := m.teamStore.ListMembers(ctx, teamID) + + if err := m.teamStore.DeleteTeam(ctx, teamID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete team: "+err.Error())) + return + } + + // Invalidate agent caches + if m.agentRouter != nil { + for _, member := range members { + m.agentRouter.InvalidateAgent(member.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Task List (admin view) --- + +type teamsTaskListParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsTaskListParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest") + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "tasks": tasks, + "count": len(tasks), + })) +} + +// --- helpers --- + +// autoCreateTeamLinks creates bidirectional agent_links between all team members. +// Silently skips existing links (UNIQUE constraint). +func (m *TeamsMethods) autoCreateTeamLinks(ctx context.Context, teamID uuid.UUID, agents []*store.AgentData, createdBy string) { + for i := 0; i < len(agents); i++ { + for j := i + 1; j < len(agents); j++ { + link := &store.AgentLinkData{ + SourceAgentID: agents[i].ID, + TargetAgentID: agents[j].ID, + Direction: store.LinkDirectionBidirectional, + TeamID: &teamID, + Description: "auto-created by team", + MaxConcurrent: 3, + Status: store.LinkStatusActive, + CreatedBy: createdBy, + } + if err := m.linkStore.CreateLink(ctx, link); err != nil { + slog.Debug("teams: auto-link already exists or failed", + "source", agents[i].AgentKey, "target", agents[j].AgentKey, "error", err) + } + } + } +} diff --git a/internal/http/agents.go b/internal/http/agents.go index 9c56dd89..18efa4e0 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -106,7 +106,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { req.OwnerID = userID if req.AgentType == "" { - req.AgentType = "open" + req.AgentType = store.AgentTypeOpen } if req.ContextWindow <= 0 { req.ContextWindow = 200000 @@ -133,10 +133,10 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { // Check if predefined agent has a description for LLM summoning description := extractDescription(req.OtherConfig) - if req.AgentType == "predefined" && description != "" && h.summoner != nil { - req.Status = "summoning" + if req.AgentType == store.AgentTypePredefined && description != "" && h.summoner != nil { + req.Status = store.AgentStatusSummoning } else if req.Status == "" { - req.Status = "active" + req.Status = store.AgentStatusActive } if err := h.agents.Create(r.Context(), &req); err != nil { @@ -151,7 +151,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } // Start LLM summoning in background if applicable - if req.Status == "summoning" { + if req.Status == store.AgentStatusSummoning { go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description) } @@ -229,8 +229,8 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { } // Invalidate caches: agent Loop + bootstrap files - h.emitCacheInvalidate("agent", ag.AgentKey) - h.emitCacheInvalidate("bootstrap", id.String()) + h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey) + h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String()) writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } @@ -260,8 +260,8 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { } // Invalidate caches: agent Loop + bootstrap files - h.emitCacheInvalidate("agent", ag.AgentKey) - h.emitCacheInvalidate("bootstrap", id.String()) + h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey) + h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String()) writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } @@ -391,7 +391,7 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can regenerate agent"}) return } - if ag.Status == "summoning" { + if ag.Status == store.AgentStatusSummoning { writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"}) return } @@ -413,14 +413,14 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) } // Set status to summoning - if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil { + if err := h.agents.Update(r.Context(), id, map[string]any{"status": store.AgentStatusSummoning}); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt) - writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"}) + writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) } // handleResummon re-runs SummonAgent from scratch using the original description. @@ -442,7 +442,7 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can resummon agent"}) return } - if ag.Status == "summoning" { + if ag.Status == store.AgentStatusSummoning { writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"}) return } @@ -457,14 +457,14 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) { return } - if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil { + if err := h.agents.Update(r.Context(), id, map[string]any{"status": store.AgentStatusSummoning}); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description) - writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"}) + writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) } // extractDescription pulls the description string from other_config JSONB. diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 6287b551..132e76f2 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -56,7 +56,7 @@ func (h *ChannelInstancesHandler) emitCacheInvalidate() { } h.msgBus.Broadcast(bus.Event{ Name: protocol.EventCacheInvalidate, - Payload: bus.CacheInvalidatePayload{Kind: "channel_instances"}, + Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances}, }) } diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go index b5a3038e..d9baebf1 100644 --- a/internal/http/custom_tools.go +++ b/internal/http/custom_tools.go @@ -58,7 +58,7 @@ func (h *CustomToolsHandler) emitCacheInvalidate(key string) { } h.msgBus.Broadcast(bus.Event{ Name: protocol.EventCacheInvalidate, - Payload: bus.CacheInvalidatePayload{Kind: "custom_tools", Key: key}, + Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindCustomTools, Key: key}, }) } diff --git a/internal/http/summoner.go b/internal/http/summoner.go index aa84b799..d65471b3 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -17,18 +17,32 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) +// Summoning event type constants. +const ( + SummonEventStarted = "started" + SummonEventFailed = "failed" + SummonEventCompleted = "completed" + SummonEventFileGenerated = "file_generated" +) + +// frontmatterKey is the special key used to store frontmatter in the parsed file map. +const frontmatterKey = "__frontmatter__" + // summoningFiles is the ordered list of context files the LLM should generate. var summoningFiles = []string{ - "SOUL.md", - "IDENTITY.md", - "AGENTS.md", - "TOOLS.md", - "HEARTBEAT.md", + bootstrap.SoulFile, + bootstrap.IdentityFile, + bootstrap.AgentsFile, + bootstrap.ToolsFile, + bootstrap.HeartbeatFile, } // fileTagRe parses content from LLM output. var fileTagRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) +// frontmatterTagRe parses short expertise summary from LLM output. +var frontmatterTagRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) + // AgentSummoner generates context files for predefined agents using an LLM. // Runs one-shot background calls — no session data, no agent loop. type AgentSummoner struct { @@ -49,26 +63,34 @@ func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry, // SummonAgent generates context files from a natural language description. // Meant to be called as a goroutine: go summoner.SummonAgent(...) // On success: stores generated files and sets agent status to "active". -// On failure: keeps template files (already seeded) and sets status to "summon_failed". +// On failure: keeps template files (already seeded) and sets status to store.AgentStatusSummonFailed. func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) { ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) defer cancel() - s.emitEvent(agentID, "started", "", "") + s.emitEvent(agentID, SummonEventStarted, "", "") files, err := s.generateFiles(ctx, providerName, model, s.buildCreatePrompt(description)) if err != nil { slog.Warn("summoning: LLM generation failed, falling back to templates", "agent", agentID, "error", err) - s.emitEvent(agentID, "failed", "", err.Error()) + s.emitEvent(agentID, SummonEventFailed, "", err.Error()) // Use fresh context — the original may have timed out, but we still need to update status. - s.setAgentStatus(context.Background(), agentID, "summon_failed") + s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } s.storeFiles(ctx, agentID, files) - s.setAgentStatus(ctx, agentID, "active") - s.emitEvent(agentID, "completed", "", "") + + // Auto-generate frontmatter from description if LLM included it + if fm, ok := files[frontmatterKey]; ok && fm != "" { + if err := s.agents.Update(ctx, agentID, map[string]any{"frontmatter": fm}); err != nil { + slog.Warn("summoning: failed to save frontmatter", "agent", agentID, "error", err) + } + } + + s.setAgentStatus(ctx, agentID, store.AgentStatusActive) + s.emitEvent(agentID, SummonEventCompleted, "", "") slog.Info("summoning: completed", "agent", agentID, "files", len(files)) } @@ -80,14 +102,14 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) defer cancel() - s.emitEvent(agentID, "started", "", "") + s.emitEvent(agentID, SummonEventStarted, "", "") // Read existing files for context existing, err := s.agents.GetAgentContextFiles(ctx, agentID) if err != nil { slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) - s.emitEvent(agentID, "failed", "", err.Error()) - s.setAgentStatus(context.Background(), agentID, "summon_failed") + s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -96,15 +118,15 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, files, err := s.generateFiles(ctx, providerName, model, prompt) if err != nil { slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) - s.emitEvent(agentID, "failed", "", err.Error()) + s.emitEvent(agentID, SummonEventFailed, "", err.Error()) // Use fresh context — the original may have timed out, but we still need to update status. - s.setAgentStatus(context.Background(), agentID, "summon_failed") + s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } s.storeFiles(ctx, agentID, files) - s.setAgentStatus(ctx, agentID, "active") - s.emitEvent(agentID, "completed", "", "") + s.setAgentStatus(ctx, agentID, store.AgentStatusActive) + s.emitEvent(agentID, SummonEventCompleted, "", "") slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) } @@ -149,7 +171,7 @@ func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) continue } - s.emitEvent(agentID, "file_generated", name, "") + s.emitEvent(agentID, SummonEventFileGenerated, name, "") } } @@ -239,7 +261,13 @@ Instructions for each file: - **TOOLS.md**: Customize with tool notes relevant to this agent's role. Keep the structure. - **HEARTBEAT.md**: Add periodic tasks if relevant to the agent's role. Leave minimal if not applicable. -Generate each file inside XML tags: +First, generate a short expertise summary (1-2 sentences, under 200 characters) describing what this agent specializes in. This is used for delegation discovery — other agents use it to decide whether to delegate tasks here. + + +(short expertise summary here) + + +Then generate each file inside XML tags: (generate here) @@ -279,7 +307,8 @@ func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, e return sb.String() } -// parseFileResponse extracts file contents from XML-tagged LLM output. +// parseFileResponse extracts file contents and frontmatter from XML-tagged LLM output. +// Frontmatter is stored under the special key "__frontmatter__". func parseFileResponse(content string) map[string]string { files := make(map[string]string) matches := fileTagRe.FindAllStringSubmatch(content, -1) @@ -290,5 +319,11 @@ func parseFileResponse(content string) map[string]string { files[name] = body } } + // Extract frontmatter tag if present + if fm := frontmatterTagRe.FindStringSubmatch(content); len(fm) > 1 { + if trimmed := strings.TrimSpace(fm[1]); trimmed != "" { + files[frontmatterKey] = trimmed + } + } return files } diff --git a/internal/memory/manager.go b/internal/memory/manager.go index 7c370738..e350287f 100644 --- a/internal/memory/manager.go +++ b/internal/memory/manager.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "sync" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" ) // ManagerConfig configures the memory manager. @@ -169,7 +171,7 @@ func (m *Manager) IndexAll(ctx context.Context) error { memoryDir := m.config.MemoryDir // Index MEMORY.md at root - memoryFile := filepath.Join(memoryDir, "MEMORY.md") + memoryFile := filepath.Join(memoryDir, bootstrap.MemoryFile) if _, err := os.Stat(memoryFile); err == nil { if err := m.IndexFile(ctx, memoryFile); err != nil { slog.Warn("failed to index MEMORY.md", "error", err) diff --git a/internal/memory/watcher.go b/internal/memory/watcher.go index 5ecbaed3..17ae33fc 100644 --- a/internal/memory/watcher.go +++ b/internal/memory/watcher.go @@ -10,6 +10,8 @@ import ( "time" "github.com/fsnotify/fsnotify" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" ) // watchDebounce is the delay before re-indexing after file changes. @@ -165,7 +167,7 @@ func (w *Watcher) handleEvent(ctx context.Context, event fsnotify.Event) { } // Only watch MEMORY.md at root or files under memory/ subdirectory - if rel != "MEMORY.md" && rel != "memory.md" && !strings.HasPrefix(rel, "memory/") && !strings.HasPrefix(rel, "memory"+string(filepath.Separator)) { + if rel != bootstrap.MemoryFile && rel != bootstrap.MemoryAltFile && !strings.HasPrefix(rel, "memory/") && !strings.HasPrefix(rel, "memory"+string(filepath.Separator)) { return } diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 1ac7233a..8529649f 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -14,6 +14,8 @@ package permissions import ( "strings" "sync" + + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // Role represents a user's permission level. @@ -146,14 +148,23 @@ func MethodScopes(method string) []Scope { func isAdminMethod(method string) bool { adminMethods := []string{ - "config.apply", - "config.patch", - "agents.create", - "agents.update", - "agents.delete", - "channels.toggle", - "device.pair.approve", - "device.pair.revoke", + protocol.MethodConfigApply, + protocol.MethodConfigPatch, + protocol.MethodAgentsCreate, + protocol.MethodAgentsUpdate, + protocol.MethodAgentsDelete, + protocol.MethodAgentsLinksList, + protocol.MethodAgentsLinksCreate, + protocol.MethodAgentsLinksUpdate, + protocol.MethodAgentsLinksDelete, + protocol.MethodChannelsToggle, + protocol.MethodPairingApprove, + protocol.MethodPairingRevoke, + protocol.MethodTeamsList, + protocol.MethodTeamsCreate, + protocol.MethodTeamsGet, + protocol.MethodTeamsDelete, + protocol.MethodTeamsTaskList, } for _, m := range adminMethods { if method == m { @@ -165,21 +176,21 @@ func isAdminMethod(method string) bool { func isWriteMethod(method string) bool { writePrefixes := []string{ - "chat.send", - "chat.abort", - "sessions.delete", - "sessions.reset", - "sessions.patch", - "cron.create", - "cron.update", - "cron.delete", - "cron.toggle", - "skills.update", + protocol.MethodChatSend, + protocol.MethodChatAbort, + protocol.MethodSessionsDelete, + protocol.MethodSessionsReset, + protocol.MethodSessionsPatch, + protocol.MethodCronCreate, + protocol.MethodCronUpdate, + protocol.MethodCronDelete, + protocol.MethodCronToggle, + protocol.MethodSkillsUpdate, "pairing.", "device.pair.", "approvals.", "exec.approval.", - "send", + protocol.MethodSend, } for _, prefix := range writePrefixes { if strings.HasPrefix(method, prefix) { diff --git a/internal/scheduler/lanes.go b/internal/scheduler/lanes.go index af0b3dec..1af97d57 100644 --- a/internal/scheduler/lanes.go +++ b/internal/scheduler/lanes.go @@ -12,10 +12,20 @@ package scheduler import ( "context" "log/slog" + "os" + "strconv" "sync" "sync/atomic" ) +// Lane name constants. +const ( + LaneMain = "main" + LaneSubagent = "subagent" + LaneDelegate = "delegate" + LaneCron = "cron" +) + // LaneConfig configures a single lane. type LaneConfig struct { Name string `json:"name"` @@ -138,14 +148,31 @@ func NewLaneManager(configs []LaneConfig) *LaneManager { } // DefaultLanes returns the standard lane configuration. +// Concurrency defaults can be overridden via env vars: +// +// GOCLAW_LANE_MAIN=30 +// GOCLAW_LANE_SUBAGENT=50 +// GOCLAW_LANE_DELEGATE=100 +// GOCLAW_LANE_CRON=30 func DefaultLanes() []LaneConfig { return []LaneConfig{ - {Name: "main", Concurrency: 30}, - {Name: "subagent", Concurrency: 30}, - {Name: "cron", Concurrency: 10}, + {Name: LaneMain, Concurrency: laneEnv("GOCLAW_LANE_MAIN", 30)}, + {Name: LaneSubagent, Concurrency: laneEnv("GOCLAW_LANE_SUBAGENT", 50)}, + {Name: LaneDelegate, Concurrency: laneEnv("GOCLAW_LANE_DELEGATE", 100)}, + {Name: LaneCron, Concurrency: laneEnv("GOCLAW_LANE_CRON", 30)}, } } +// laneEnv reads an int from an env var, falling back to defaultVal. +func laneEnv(key string, defaultVal int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultVal +} + // Get returns a lane by name. Returns the "main" lane as fallback. func (lm *LaneManager) Get(name string) *Lane { lm.mu.RLock() @@ -156,7 +183,7 @@ func (lm *LaneManager) Get(name string) *Lane { } // Fallback to main lane - if lane, ok := lm.lanes["main"]; ok { + if lane, ok := lm.lanes[LaneMain]; ok { return lane } diff --git a/internal/scheduler/queue.go b/internal/scheduler/queue.go index 194da628..d866745a 100644 --- a/internal/scheduler/queue.go +++ b/internal/scheduler/queue.go @@ -240,7 +240,7 @@ func (sq *SessionQueue) startOne(ctx context.Context) { lane := sq.laneMgr.Get(sq.lane) if lane == nil { - lane = sq.laneMgr.Get("main") + lane = sq.laneMgr.Get(LaneMain) } if lane == nil { diff --git a/internal/store/agent_link_store.go b/internal/store/agent_link_store.go new file mode 100644 index 00000000..814695c8 --- /dev/null +++ b/internal/store/agent_link_store.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" +) + +// Link direction constants. +const ( + LinkDirectionOutbound = "outbound" + LinkDirectionInbound = "inbound" + LinkDirectionBidirectional = "bidirectional" +) + +// Link status constants. +const ( + LinkStatusActive = "active" + LinkStatusDisabled = "disabled" +) + +// AgentLinkData represents a directional link between two agents for delegation. +type AgentLinkData struct { + BaseModel + SourceAgentID uuid.UUID `json:"source_agent_id"` + TargetAgentID uuid.UUID `json:"target_agent_id"` + Direction string `json:"direction"` // "outbound", "inbound", "bidirectional" + TeamID *uuid.UUID `json:"team_id,omitempty"` // non-nil = auto-created by team + Description string `json:"description,omitempty"` + MaxConcurrent int `json:"max_concurrent"` + Settings json.RawMessage `json:"settings,omitempty"` + Status string `json:"status"` // "active", "disabled" + CreatedBy string `json:"created_by"` + + // Joined fields (populated by queries that JOIN agents table) + SourceAgentKey string `json:"source_agent_key,omitempty"` + TargetAgentKey string `json:"target_agent_key,omitempty"` + TargetDisplayName string `json:"target_display_name,omitempty"` + TargetDescription string `json:"target_description,omitempty"` + TeamName string `json:"team_name,omitempty"` // from LEFT JOIN agent_teams +} + +// AgentLinkStore manages inter-agent delegation links. +type AgentLinkStore interface { + CreateLink(ctx context.Context, link *AgentLinkData) error + DeleteLink(ctx context.Context, id uuid.UUID) error + UpdateLink(ctx context.Context, id uuid.UUID, updates map[string]any) error + GetLink(ctx context.Context, id uuid.UUID) (*AgentLinkData, error) + + // ListLinksFrom returns all links where agentID is the source. + ListLinksFrom(ctx context.Context, agentID uuid.UUID) ([]AgentLinkData, error) + + // ListLinksTo returns all links where agentID is the target. + ListLinksTo(ctx context.Context, agentID uuid.UUID) ([]AgentLinkData, error) + + // CanDelegate checks if fromAgent can delegate to toAgent considering direction. + CanDelegate(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (bool, error) + + // GetLinkBetween returns the active link allowing fromAgent to delegate to toAgent. + // Returns full link data including Settings for per-user permission checks. + // Returns nil, nil if no matching link exists. + GetLinkBetween(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (*AgentLinkData, error) + + // DelegateTargets returns all agents that fromAgent can delegate to, + // with joined agent_key and display_name for AGENTS.md generation. + DelegateTargets(ctx context.Context, fromAgentID uuid.UUID) ([]AgentLinkData, error) + + // SearchDelegateTargets performs FTS search over delegation targets. + SearchDelegateTargets(ctx context.Context, fromAgentID uuid.UUID, query string, limit int) ([]AgentLinkData, error) + + // SearchDelegateTargetsByEmbedding performs vector similarity search over delegation targets. + SearchDelegateTargetsByEmbedding(ctx context.Context, fromAgentID uuid.UUID, embedding []float32, limit int) ([]AgentLinkData, error) +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index a2024105..e56fc820 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -8,11 +8,26 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" ) +// Agent type constants. +const ( + AgentTypeOpen = "open" // per-user context files, seeded on first chat + AgentTypePredefined = "predefined" // shared agent-level context files +) + +// Agent status constants. +const ( + AgentStatusActive = "active" + AgentStatusInactive = "inactive" + AgentStatusSummoning = "summoning" + AgentStatusSummonFailed = "summon_failed" +) + // AgentData represents an agent in the database. type AgentData struct { BaseModel AgentKey string `json:"agent_key"` DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt) OwnerID string `json:"owner_id"` Provider string `json:"provider"` Model string `json:"model"` diff --git a/internal/store/file/agents.go b/internal/store/file/agents.go index c5c38ab5..9e3c0c7c 100644 --- a/internal/store/file/agents.go +++ b/internal/store/file/agents.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" _ "modernc.org/sqlite" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -27,12 +28,12 @@ func AgentUUID(key string) uuid.UUID { // agentContextFiles lists filenames read from the workspace root for agent-level context. var agentContextFiles = []string{ - "AGENTS.md", - "SOUL.md", - "TOOLS.md", - "IDENTITY.md", - "HEARTBEAT.md", - "BOOTSTRAP.md", + bootstrap.AgentsFile, + bootstrap.SoulFile, + bootstrap.ToolsFile, + bootstrap.IdentityFile, + bootstrap.HeartbeatFile, + bootstrap.BootstrapFile, } // AgentEntry describes an agent to register in the FileAgentStore. @@ -86,7 +87,7 @@ func NewFileAgentStore(dbPath string, entries []AgentEntry) (*FileAgentStore, er id := AgentUUID(e.Key) agentType := e.AgentType if agentType == "" { - agentType = "open" + agentType = store.AgentTypeOpen } entry := &agentEntry{ data: &store.AgentData{ @@ -98,7 +99,7 @@ func NewFileAgentStore(dbPath string, entries []AgentEntry) (*FileAgentStore, er AgentKey: e.Key, AgentType: agentType, Workspace: e.Workspace, - Status: "active", + Status: store.AgentStatusActive, }, workspace: e.Workspace, } diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go new file mode 100644 index 00000000..4710d116 --- /dev/null +++ b/internal/store/pg/agent_links.go @@ -0,0 +1,286 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGAgentLinkStore implements store.AgentLinkStore backed by Postgres. +type PGAgentLinkStore struct { + db *sql.DB +} + +func NewPGAgentLinkStore(db *sql.DB) *PGAgentLinkStore { + return &PGAgentLinkStore{db: db} +} + +const linkSelectCols = `id, source_agent_id, target_agent_id, direction, team_id, description, + max_concurrent, settings, status, created_by, created_at, updated_at` + +// linkSelectColsJoined prefixes every column with l. to avoid ambiguity in JOINs. +const linkSelectColsJoined = `l.id, l.source_agent_id, l.target_agent_id, l.direction, l.team_id, l.description, + l.max_concurrent, l.settings, l.status, l.created_by, l.created_at, l.updated_at` + +func (s *PGAgentLinkStore) CreateLink(ctx context.Context, link *store.AgentLinkData) error { + if link.ID == uuid.Nil { + link.ID = store.GenNewID() + } + now := time.Now() + link.CreatedAt = now + link.UpdatedAt = now + + settings := link.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_links (id, source_agent_id, target_agent_id, direction, team_id, description, + max_concurrent, settings, status, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + link.ID, link.SourceAgentID, link.TargetAgentID, link.Direction, link.TeamID, link.Description, + link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, + ) + return err +} + +func (s *PGAgentLinkStore) DeleteLink(ctx context.Context, id uuid.UUID) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id) + return err +} + +func (s *PGAgentLinkStore) UpdateLink(ctx context.Context, id uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + updates["updated_at"] = time.Now() + return execMapUpdate(ctx, s.db, "agent_links", id, updates) +} + +func (s *PGAgentLinkStore) GetLink(ctx context.Context, id uuid.UUID) (*store.AgentLinkData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id) + return scanLinkRow(row) +} + +func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+linkSelectColsJoined+`, + sa.agent_key AS source_agent_key, + ta.agent_key AS target_agent_key, + COALESCE(ta.display_name, '') AS target_display_name, + COALESCE(ta.frontmatter, '') AS target_description, + COALESCE(tm.name, '') AS team_name + FROM agent_links l + JOIN agents sa ON sa.id = l.source_agent_id + JOIN agents ta ON ta.id = l.target_agent_id + LEFT JOIN agent_teams tm ON tm.id = l.team_id + WHERE l.source_agent_id = $1 + ORDER BY l.created_at`, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLinkRowsJoined(rows) +} + +func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+linkSelectColsJoined+`, + sa.agent_key AS source_agent_key, + ta.agent_key AS target_agent_key, + COALESCE(ta.display_name, '') AS target_display_name, + COALESCE(ta.frontmatter, '') AS target_description, + COALESCE(tm.name, '') AS team_name + FROM agent_links l + JOIN agents sa ON sa.id = l.source_agent_id + JOIN agents ta ON ta.id = l.target_agent_id + LEFT JOIN agent_teams tm ON tm.id = l.team_id + WHERE l.target_agent_id = $1 + ORDER BY l.created_at`, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLinkRowsJoined(rows) +} + +func (s *PGAgentLinkStore) CanDelegate(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (bool, error) { + var exists bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS( + SELECT 1 FROM agent_links WHERE status = 'active' AND ( + (source_agent_id = $1 AND target_agent_id = $2 AND direction IN ('outbound', 'bidirectional')) + OR + (source_agent_id = $2 AND target_agent_id = $1 AND direction IN ('inbound', 'bidirectional')) + ) + )`, fromAgentID, toAgentID).Scan(&exists) + return exists, err +} + +func (s *PGAgentLinkStore) DelegateTargets(ctx context.Context, fromAgentID uuid.UUID) ([]store.AgentLinkData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+linkSelectColsJoined+`, + sa.agent_key AS source_agent_key, + ta.agent_key AS target_agent_key, + COALESCE(ta.display_name, '') AS target_display_name, + COALESCE(ta.frontmatter, '') AS target_description, + COALESCE(tm.name, '') AS team_name + FROM agent_links l + JOIN agents sa ON sa.id = l.source_agent_id + JOIN agents ta ON ta.id = l.target_agent_id + LEFT JOIN agent_teams tm ON tm.id = l.team_id + WHERE l.status = 'active' AND ( + (l.source_agent_id = $1 AND l.direction IN ('outbound', 'bidirectional')) + OR + (l.target_agent_id = $1 AND l.direction IN ('inbound', 'bidirectional')) + ) + ORDER BY ta.agent_key`, fromAgentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var links []store.AgentLinkData + for rows.Next() { + var d store.AgentLinkData + var desc sql.NullString + if err := rows.Scan( + &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.Direction, &d.TeamID, &desc, + &d.MaxConcurrent, &d.Settings, &d.Status, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + &d.SourceAgentKey, &d.TargetAgentKey, &d.TargetDisplayName, &d.TargetDescription, + &d.TeamName, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + + // For links where this agent is the target (inbound direction), + // swap to show the actual target (the other agent) as the delegate target. + if d.TargetAgentID == fromAgentID { + d.TargetAgentID = d.SourceAgentID + d.TargetAgentKey = d.SourceAgentKey + } + + links = append(links, d) + } + return links, rows.Err() +} + +func (s *PGAgentLinkStore) GetLinkBetween(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (*store.AgentLinkData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+linkSelectCols+` + FROM agent_links WHERE status = 'active' AND ( + (source_agent_id = $1 AND target_agent_id = $2 AND direction IN ('outbound', 'bidirectional')) + OR + (source_agent_id = $2 AND target_agent_id = $1 AND direction IN ('inbound', 'bidirectional')) + ) LIMIT 1`, fromAgentID, toAgentID) + d, err := scanLinkRow(row) + if err != nil { + return nil, nil // no link found + } + return d, nil +} + +func (s *PGAgentLinkStore) SearchDelegateTargets(ctx context.Context, fromAgentID uuid.UUID, query string, limit int) ([]store.AgentLinkData, error) { + if limit <= 0 { + limit = 5 + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+linkSelectColsJoined+`, + sa.agent_key AS source_agent_key, + ta.agent_key AS target_agent_key, + COALESCE(ta.display_name, '') AS target_display_name, + COALESCE(ta.frontmatter, '') AS target_description, + COALESCE(tm.name, '') AS team_name + FROM agent_links l + JOIN agents sa ON sa.id = l.source_agent_id + JOIN agents ta ON ta.id = l.target_agent_id + LEFT JOIN agent_teams tm ON tm.id = l.team_id + WHERE l.status = 'active' + AND (l.source_agent_id = $1 AND l.direction IN ('outbound', 'bidirectional')) + AND ta.tsv @@ plainto_tsquery('simple', $2) + ORDER BY ts_rank(ta.tsv, plainto_tsquery('simple', $2)) DESC + LIMIT $3`, fromAgentID, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLinkRowsJoined(rows) +} + +func (s *PGAgentLinkStore) SearchDelegateTargetsByEmbedding(ctx context.Context, fromAgentID uuid.UUID, embedding []float32, limit int) ([]store.AgentLinkData, error) { + if limit <= 0 { + limit = 5 + } + vecStr := vectorToString(embedding) + rows, err := s.db.QueryContext(ctx, + `SELECT `+linkSelectColsJoined+`, + sa.agent_key AS source_agent_key, + ta.agent_key AS target_agent_key, + COALESCE(ta.display_name, '') AS target_display_name, + COALESCE(ta.frontmatter, '') AS target_description, + COALESCE(tm.name, '') AS team_name + FROM agent_links l + JOIN agents sa ON sa.id = l.source_agent_id + JOIN agents ta ON ta.id = l.target_agent_id + LEFT JOIN agent_teams tm ON tm.id = l.team_id + WHERE l.status = 'active' + AND (l.source_agent_id = $1 AND l.direction IN ('outbound', 'bidirectional')) + AND ta.embedding IS NOT NULL + ORDER BY ta.embedding <=> $2::vector + LIMIT $3`, fromAgentID, vecStr, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLinkRowsJoined(rows) +} + +// --- scan helpers --- + +func scanLinkRow(row *sql.Row) (*store.AgentLinkData, error) { + var d store.AgentLinkData + var desc sql.NullString + err := row.Scan( + &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.Direction, &d.TeamID, &desc, + &d.MaxConcurrent, &d.Settings, &d.Status, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("link not found: %w", err) + } + if desc.Valid { + d.Description = desc.String + } + return &d, nil +} + +func scanLinkRowsJoined(rows *sql.Rows) ([]store.AgentLinkData, error) { + var links []store.AgentLinkData + for rows.Next() { + var d store.AgentLinkData + var desc sql.NullString + if err := rows.Scan( + &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.Direction, &d.TeamID, &desc, + &d.MaxConcurrent, &d.Settings, &d.Status, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + &d.SourceAgentKey, &d.TargetAgentKey, &d.TargetDisplayName, &d.TargetDescription, + &d.TeamName, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + links = append(links, d) + } + return links, rows.Err() +} diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 0025071d..39babb46 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" "github.com/google/uuid" @@ -13,15 +14,92 @@ import ( // PGAgentStore implements store.AgentStore backed by Postgres. type PGAgentStore struct { - db *sql.DB + db *sql.DB + embProvider store.EmbeddingProvider // optional: for agent frontmatter embeddings } func NewPGAgentStore(db *sql.DB) *PGAgentStore { return &PGAgentStore{db: db} } +// SetEmbeddingProvider sets the embedding provider for agent frontmatter vectors. +func (s *PGAgentStore) SetEmbeddingProvider(provider store.EmbeddingProvider) { + s.embProvider = provider +} + +// generateAgentEmbedding creates an embedding for an agent's displayName+frontmatter and stores it. +func (s *PGAgentStore) generateAgentEmbedding(ctx context.Context, agentID uuid.UUID, displayName, frontmatter string) { + if s.embProvider == nil || frontmatter == "" { + return + } + text := displayName + if frontmatter != "" { + text += ": " + frontmatter + } + embeddings, err := s.embProvider.Embed(ctx, []string{text}) + if err != nil || len(embeddings) == 0 || len(embeddings[0]) == 0 { + slog.Warn("agent embedding generation failed", "agent", agentID, "error", err) + return + } + vecStr := vectorToString(embeddings[0]) + if _, err := s.db.ExecContext(ctx, `UPDATE agents SET embedding = $1::vector WHERE id = $2`, vecStr, agentID); err != nil { + slog.Warn("agent embedding update failed", "agent", agentID, "error", err) + } +} + +// BackfillAgentEmbeddings generates embeddings for all active agents that have frontmatter but no embedding. +func (s *PGAgentStore) BackfillAgentEmbeddings(ctx context.Context) (int, error) { + if s.embProvider == nil { + return 0, nil + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, COALESCE(display_name, ''), COALESCE(frontmatter, '') + FROM agents WHERE deleted_at IS NULL AND frontmatter IS NOT NULL AND frontmatter != '' AND embedding IS NULL`) + if err != nil { + return 0, err + } + defer rows.Close() + + type agentRow struct { + id uuid.UUID + displayName string + frontmatter string + } + var pending []agentRow + for rows.Next() { + var r agentRow + if err := rows.Scan(&r.id, &r.displayName, &r.frontmatter); err != nil { + continue + } + pending = append(pending, r) + } + if len(pending) == 0 { + return 0, nil + } + + slog.Info("backfilling agent embeddings", "count", len(pending)) + updated := 0 + for _, ag := range pending { + text := ag.displayName + if ag.frontmatter != "" { + text += ": " + ag.frontmatter + } + embeddings, err := s.embProvider.Embed(ctx, []string{text}) + if err != nil || len(embeddings) == 0 || len(embeddings[0]) == 0 { + continue + } + vecStr := vectorToString(embeddings[0]) + if _, err := s.db.ExecContext(ctx, `UPDATE agents SET embedding = $1::vector WHERE id = $2`, vecStr, ag.id); err != nil { + continue + } + updated++ + } + slog.Info("agent embeddings backfill complete", "updated", updated) + return updated, nil +} + // agentSelectCols is the column list for all agent SELECT queries. -const agentSelectCols = `id, agent_key, display_name, owner_id, provider, model, +const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, provider, model, context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, @@ -35,19 +113,27 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error agent.CreatedAt = now agent.UpdatedAt = now _, err := s.db.ExecContext(ctx, - `INSERT INTO agents (id, agent_key, display_name, owner_id, provider, model, + `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model, context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, agent_type, is_default, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, - agent.ID, agent.AgentKey, agent.DisplayName, agent.OwnerID, agent.Provider, agent.Model, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, + agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model, agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace, jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig), jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig), agent.AgentType, agent.IsDefault, agent.Status, now, now, ) - return err + if err != nil { + return err + } + + // Generate embedding for new agent with frontmatter + if agent.Frontmatter != "" && s.embProvider != nil { + go s.generateAgentEmbedding(context.Background(), agent.ID, agent.DisplayName, agent.Frontmatter) + } + return nil } func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) { @@ -78,7 +164,21 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str return nil } updates["updated_at"] = time.Now() - return execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id) + err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id) + if err != nil { + return err + } + + // Regenerate embedding when frontmatter changes + if _, hasFrontmatter := updates["frontmatter"]; hasFrontmatter && s.embProvider != nil { + go func() { + ag, agErr := s.GetByID(context.Background(), id) + if agErr == nil { + s.generateAgentEmbedding(context.Background(), id, ag.DisplayName, ag.Frontmatter) + } + }() + } + return nil } func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error { @@ -200,15 +300,19 @@ type agentRowScanner interface { func scanAgentRow(row agentRowScanner) (*store.AgentData, error) { var d store.AgentData + var frontmatter sql.NullString // pgx: scan nullable JSONB into *[]byte (NOT *json.RawMessage — pgx can't scan NULL into defined types) var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte - err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &d.OwnerID, &d.Provider, &d.Model, + err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model, &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace, &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg, &d.AgentType, &d.IsDefault, &d.Status, &d.CreatedAt, &d.UpdatedAt) if err != nil { return nil, err } + if frontmatter.Valid { + d.Frontmatter = frontmatter.String + } // Convert *[]byte → json.RawMessage (nil-safe) if toolsCfg != nil { d.ToolsConfig = *toolsCfg diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index f9a564e7..d8c87fd7 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -33,5 +33,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { CustomTools: NewPGCustomToolStore(db, cfg.EncryptionKey), ChannelInstances: NewPGChannelInstanceStore(db, cfg.EncryptionKey), ConfigSecrets: NewPGConfigSecretsStore(db, cfg.EncryptionKey), + AgentLinks: NewPGAgentLinkStore(db), + Teams: NewPGTeamStore(db), }, nil } diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go new file mode 100644 index 00000000..95874825 --- /dev/null +++ b/internal/store/pg/teams.go @@ -0,0 +1,381 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGTeamStore implements store.TeamStore backed by Postgres. +type PGTeamStore struct { + db *sql.DB +} + +func NewPGTeamStore(db *sql.DB) *PGTeamStore { + return &PGTeamStore{db: db} +} + +// --- Column constants --- + +const teamSelectCols = `id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at` + +const taskSelectCols = `id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, created_at, updated_at` + +const messageSelectCols = `id, team_id, from_agent_id, to_agent_id, content, message_type, read, created_at` + +// ============================================================ +// Team CRUD +// ============================================================ + +func (s *PGTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) error { + if team.ID == uuid.Nil { + team.ID = store.GenNewID() + } + now := time.Now() + team.CreatedAt = now + team.UpdatedAt = now + + settings := team.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + team.ID, team.Name, team.LeadAgentID, team.Description, + team.Status, settings, team.CreatedBy, now, now, + ) + return err +} + +func (s *PGTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID) + return scanTeamRow(row) +} + +func (s *PGTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID) + return err +} + +func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS lead_agent_key + FROM agent_teams t + LEFT JOIN agents a ON a.id = t.lead_agent_id + ORDER BY t.created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var teams []store.TeamData + for rows.Next() { + var d store.TeamData + var desc sql.NullString + if err := rows.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + &d.LeadAgentKey, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + teams = append(teams, d) + } + return teams, rows.Err() +} + +// ============================================================ +// Members +// ============================================================ + +func (s *PGTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (team_id, agent_id) DO UPDATE SET role = EXCLUDED.role`, + teamID, agentID, role, time.Now(), + ) + return err +} + +func (s *PGTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM agent_team_members WHERE team_id = $1 AND agent_id = $2`, + teamID, agentID, + ) + return err +} + +func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + COALESCE(a.agent_key, '') AS agent_key, + COALESCE(a.display_name, '') AS display_name, + COALESCE(a.frontmatter, '') AS frontmatter + FROM agent_team_members m + JOIN agents a ON a.id = m.agent_id + WHERE m.team_id = $1 + ORDER BY m.joined_at`, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []store.TeamMemberData + for rows.Next() { + var d store.TeamMemberData + if err := rows.Scan( + &d.TeamID, &d.AgentID, &d.Role, &d.JoinedAt, + &d.AgentKey, &d.DisplayName, &d.Frontmatter, + ); err != nil { + return nil, err + } + members = append(members, d) + } + return members, rows.Err() +} + +func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at + FROM agent_teams t + JOIN agent_team_members m ON m.team_id = t.id + WHERE m.agent_id = $1 AND t.status = $2 + LIMIT 1`, agentID, store.TeamStatusActive) + + d, err := scanTeamRow(row) + if err == sql.ErrNoRows { + return nil, nil + } + return d, err +} + +// ============================================================ +// Tasks +// ============================================================ + +func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error { + if task.ID == uuid.Nil { + task.ID = store.GenNewID() + } + now := time.Now() + task.CreatedAt = now + task.UpdatedAt = now + + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + task.ID, task.TeamID, task.Subject, task.Description, + task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy), + task.Priority, task.Result, now, now, + ) + return err +} + +func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + updates["updated_at"] = time.Now() + return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) +} + +func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string) ([]store.TeamTaskData, error) { + orderClause := "t.priority DESC, t.created_at" + if orderBy == "newest" { + orderClause = "t.created_at DESC" + } + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.created_at, t.updated_at, + COALESCE(a.agent_key, '') AS owner_agent_key + FROM team_tasks t + LEFT JOIN agents a ON a.id = t.owner_agent_id + WHERE t.team_id = $1 + ORDER BY `+orderClause, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTaskRowsJoined(rows) +} + +func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID uuid.UUID) error { + res, err := s.db.ExecContext(ctx, + `UPDATE team_tasks SET status = $1, owner_agent_id = $2, updated_at = $3 + WHERE id = $4 AND status = $5 AND owner_agent_id IS NULL`, + store.TeamTaskStatusInProgress, agentID, time.Now(), + taskID, 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 claiming (already claimed or not pending)") + } + return nil +} + +func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID uuid.UUID, result string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Mark task as completed (must be in_progress — use ClaimTask first) + res, err := tx.ExecContext(ctx, + `UPDATE team_tasks SET status = $1, result = $2, updated_at = $3 + WHERE id = $4 AND status = $5`, + store.TeamTaskStatusCompleted, result, time.Now(), + taskID, 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") + } + + // Unblock dependent tasks: remove this taskID from their blocked_by arrays. + // Tasks with empty blocked_by after removal become claimable. + _, err = tx.ExecContext(ctx, + `UPDATE team_tasks SET blocked_by = array_remove(blocked_by, $1), updated_at = $2 + WHERE $1 = ANY(blocked_by)`, + taskID, time.Now(), + ) + if err != nil { + return err + } + + return tx.Commit() +} + +// ============================================================ +// Messages +// ============================================================ + +func (s *PGTeamStore) SendMessage(ctx context.Context, msg *store.TeamMessageData) error { + if msg.ID == uuid.Nil { + msg.ID = store.GenNewID() + } + msg.CreatedAt = time.Now() + + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_messages (id, team_id, from_agent_id, to_agent_id, content, message_type, read, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + msg.ID, msg.TeamID, msg.FromAgentID, msg.ToAgentID, + msg.Content, msg.MessageType, false, msg.CreatedAt, + ) + return err +} + +func (s *PGTeamStore) GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]store.TeamMessageData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, m.team_id, m.from_agent_id, m.to_agent_id, m.content, m.message_type, m.read, m.created_at, + COALESCE(fa.agent_key, '') AS from_agent_key, + COALESCE(ta.agent_key, '') AS to_agent_key + FROM team_messages m + LEFT JOIN agents fa ON fa.id = m.from_agent_id + LEFT JOIN agents ta ON ta.id = m.to_agent_id + WHERE m.team_id = $1 AND (m.to_agent_id = $2 OR m.to_agent_id IS NULL) AND m.read = false + ORDER BY m.created_at`, teamID, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanMessageRowsJoined(rows) +} + +func (s *PGTeamStore) MarkRead(ctx context.Context, messageID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `UPDATE team_messages SET read = true WHERE id = $1`, messageID) + return err +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTeamRow(row *sql.Row) (*store.TeamData, error) { + var d store.TeamData + var desc sql.NullString + err := row.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + return &d, nil +} + +func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) { + var tasks []store.TeamTaskData + for rows.Next() { + var d store.TeamTaskData + var desc, result sql.NullString + var ownerID *uuid.UUID + var blockedBy []uuid.UUID + if err := rows.Scan( + &d.ID, &d.TeamID, &d.Subject, &desc, &d.Status, + &ownerID, pq.Array(&blockedBy), &d.Priority, &result, + &d.CreatedAt, &d.UpdatedAt, + &d.OwnerAgentKey, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + if result.Valid { + d.Result = &result.String + } + d.OwnerAgentID = ownerID + d.BlockedBy = blockedBy + tasks = append(tasks, d) + } + return tasks, rows.Err() +} + +func scanMessageRowsJoined(rows *sql.Rows) ([]store.TeamMessageData, error) { + var messages []store.TeamMessageData + for rows.Next() { + var d store.TeamMessageData + var toAgentID *uuid.UUID + if err := rows.Scan( + &d.ID, &d.TeamID, &d.FromAgentID, &toAgentID, + &d.Content, &d.MessageType, &d.Read, &d.CreatedAt, + &d.FromAgentKey, &d.ToAgentKey, + ); err != nil { + return nil, err + } + d.ToAgentID = toAgentID + messages = append(messages, d) + } + return messages, rows.Err() +} diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index 352b9058..67f60790 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -27,12 +27,12 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData trace.ID = store.GenNewID() } _, err := s.db.ExecContext(ctx, - `INSERT INTO traces (id, agent_id, user_id, session_key, run_id, start_time, end_time, + `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, - trace.ID, nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, + trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime), nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel), nilStr(trace.InputPreview), nilStr(trace.OutputPreview), @@ -48,7 +48,7 @@ func (s *PGTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, upd func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) { var d store.TraceData - var agentID *uuid.UUID + var parentTraceID, agentID *uuid.UUID var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string var endTime *time.Time var durationMS *int @@ -56,12 +56,12 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor var tags []byte err := s.db.QueryRowContext(ctx, - `SELECT id, agent_id, user_id, session_key, run_id, start_time, end_time, + `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at FROM traces WHERE id = $1`, traceID, - ).Scan(&d.ID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, + ).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, &d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &d.CreatedAt) @@ -69,6 +69,7 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor return nil, err } + d.ParentTraceID = parentTraceID d.AgentID = agentID d.UserID = derefStr(userID) d.SessionKey = derefStr(sessionKey) @@ -132,7 +133,7 @@ func (s *PGTracingStore) CountTraces(ctx context.Context, opts store.TraceListOp func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) { where, args := buildTraceWhere(opts) - q := `SELECT id, agent_id, user_id, session_key, run_id, start_time, end_time, + q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at @@ -153,20 +154,21 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt var result []store.TraceData for rows.Next() { var d store.TraceData - var agentID *uuid.UUID + var parentTraceID, agentID *uuid.UUID var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string var endTime *time.Time var durationMS *int var metadata *[]byte var tags []byte - if err := rows.Scan(&d.ID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, + if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, &d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &d.CreatedAt); err != nil { continue } + d.ParentTraceID = parentTraceID d.AgentID = agentID d.UserID = derefStr(userID) d.SessionKey = derefStr(sessionKey) diff --git a/internal/store/stores.go b/internal/store/stores.go index a9cc5d53..a8c8c94e 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -15,4 +15,6 @@ type Stores struct { CustomTools CustomToolStore // nil in standalone mode ChannelInstances ChannelInstanceStore // nil in standalone mode ConfigSecrets ConfigSecretsStore // nil in standalone mode + AgentLinks AgentLinkStore // nil in standalone mode + Teams TeamStore // nil in standalone mode } diff --git a/internal/store/team_store.go b/internal/store/team_store.go new file mode 100644 index 00000000..96e87dd1 --- /dev/null +++ b/internal/store/team_store.go @@ -0,0 +1,130 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// Team status constants. +const ( + TeamStatusActive = "active" + TeamStatusArchived = "archived" +) + +// Team member role constants. +const ( + TeamRoleLead = "lead" + TeamRoleMember = "member" +) + +// Team task status constants. +const ( + TeamTaskStatusPending = "pending" + TeamTaskStatusInProgress = "in_progress" + TeamTaskStatusCompleted = "completed" + TeamTaskStatusBlocked = "blocked" +) + +// Team message type constants. +const ( + TeamMessageTypeChat = "chat" + TeamMessageTypeBroadcast = "broadcast" +) + +// TeamData represents an agent team. +type TeamData struct { + BaseModel + Name string `json:"name"` + LeadAgentID uuid.UUID `json:"lead_agent_id"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Settings json.RawMessage `json:"settings,omitempty"` + CreatedBy string `json:"created_by"` + + // Joined fields (populated by queries that JOIN agents table) + LeadAgentKey string `json:"lead_agent_key,omitempty"` +} + +// TeamMemberData represents a team member. +type TeamMemberData struct { + TeamID uuid.UUID `json:"team_id"` + AgentID uuid.UUID `json:"agent_id"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` + + // Joined fields + AgentKey string `json:"agent_key,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` +} + +// TeamTaskData represents a task in the team's shared task list. +type TeamTaskData struct { + BaseModel + TeamID uuid.UUID `json:"team_id"` + Subject string `json:"subject"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + OwnerAgentID *uuid.UUID `json:"owner_agent_id,omitempty"` + BlockedBy []uuid.UUID `json:"blocked_by,omitempty"` + Priority int `json:"priority"` + Result *string `json:"result,omitempty"` + + // Joined fields + OwnerAgentKey string `json:"owner_agent_key,omitempty"` +} + +// TeamMessageData represents a message in the team mailbox. +type TeamMessageData struct { + ID uuid.UUID `json:"id"` + TeamID uuid.UUID `json:"team_id"` + FromAgentID uuid.UUID `json:"from_agent_id"` + ToAgentID *uuid.UUID `json:"to_agent_id,omitempty"` + Content string `json:"content"` + MessageType string `json:"message_type"` + Read bool `json:"read"` + CreatedAt time.Time `json:"created_at"` + + // Joined fields + FromAgentKey string `json:"from_agent_key,omitempty"` + ToAgentKey string `json:"to_agent_key,omitempty"` +} + +// TeamStore manages agent teams, tasks, and messages. +type TeamStore interface { + // Team CRUD + CreateTeam(ctx context.Context, team *TeamData) error + GetTeam(ctx context.Context, teamID uuid.UUID) (*TeamData, error) + DeleteTeam(ctx context.Context, teamID uuid.UUID) error + ListTeams(ctx context.Context) ([]TeamData, error) + + // Members + AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error + RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error + ListMembers(ctx context.Context, teamID uuid.UUID) ([]TeamMemberData, error) + + // GetTeamForAgent returns the team that the given agent belongs to. + // Returns nil, nil if the agent is not in any team. + GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*TeamData, error) + + // Tasks (shared task list) + CreateTask(ctx context.Context, task *TeamTaskData) error + UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error + // ListTasks returns tasks for a team. orderBy: "priority" (default) or "newest". + ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string) ([]TeamTaskData, error) + + // ClaimTask atomically transitions a task from pending to in_progress. + // Only one agent can claim a given task (row-level lock, race-safe). + ClaimTask(ctx context.Context, taskID, agentID uuid.UUID) error + + // CompleteTask marks a task as completed and unblocks dependent tasks. + CompleteTask(ctx context.Context, taskID uuid.UUID, result string) error + + // Messages (mailbox) + SendMessage(ctx context.Context, msg *TeamMessageData) error + GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]TeamMessageData, error) + MarkRead(ctx context.Context, messageID uuid.UUID) error +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 28d0319b..e104d4d5 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -8,9 +8,39 @@ import ( "github.com/google/uuid" ) +// Trace status constants. +const ( + TraceStatusRunning = "running" + TraceStatusCompleted = "completed" + TraceStatusError = "error" + TraceStatusCancelled = "cancelled" +) + +// Span type constants. +const ( + SpanTypeLLMCall = "llm_call" + SpanTypeToolCall = "tool_call" + SpanTypeAgent = "agent" + SpanTypeEmbedding = "embedding" + SpanTypeEvent = "event" +) + +// Span status constants. +const ( + SpanStatusCompleted = "completed" + SpanStatusError = "error" + SpanStatusRunning = "running" +) + +// Span level constants. +const ( + SpanLevelDefault = "DEFAULT" +) + // TraceData represents a top-level trace (one per user request). type TraceData struct { ID uuid.UUID `json:"id"` + ParentTraceID *uuid.UUID `json:"parent_trace_id,omitempty"` // linked parent trace (delegation) AgentID *uuid.UUID `json:"agent_id,omitempty"` UserID string `json:"user_id,omitempty"` SessionKey string `json:"session_key,omitempty"` diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 1515806d..26a1a3a7 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -18,22 +18,22 @@ import ( // protectedFileSet defines files that require group file writer permission in group chats. // These files control the agent's identity and behavior — only allowlisted users can modify them. var protectedFileSet = map[string]bool{ - "SOUL.md": true, - "IDENTITY.md": true, - "AGENTS.md": true, - "HEARTBEAT.md": true, - "USER.md": true, + bootstrap.SoulFile: true, + bootstrap.IdentityFile: true, + bootstrap.AgentsFile: true, + bootstrap.HeartbeatFile: true, + bootstrap.UserFile: true, } // contextFileSet is the set of filenames routed to DB in managed mode. var contextFileSet = map[string]bool{ - "SOUL.md": true, - "AGENTS.md": true, - "TOOLS.md": true, - "IDENTITY.md": true, - "HEARTBEAT.md": true, - "USER.md": true, - "BOOTSTRAP.md": true, // first-run file (deleted after completion) + bootstrap.SoulFile: true, + bootstrap.AgentsFile: true, + bootstrap.ToolsFile: true, + bootstrap.IdentityFile: true, + bootstrap.HeartbeatFile: true, + bootstrap.UserFile: true, + bootstrap.BootstrapFile: true, // first-run file (deleted after completion) } // isContextFile checks if a path refers to a workspace-root context file. @@ -123,7 +123,7 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str agentType := store.AgentTypeFromContext(ctx) // Open agent: ALL files per-user → fallback to agent-level - if agentType == "open" && userID != "" { + if agentType == store.AgentTypeOpen && userID != "" { content, handled, err := b.readUserFile(ctx, agentID, userID, fileName) if err != nil { return "", handled, err @@ -136,7 +136,7 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str } // Predefined agent: USER.md and BOOTSTRAP.md per-user - if agentType == "predefined" && userID != "" && (fileName == "USER.md" || fileName == "BOOTSTRAP.md") { + if agentType == store.AgentTypePredefined && userID != "" && (fileName == bootstrap.UserFile || fileName == bootstrap.BootstrapFile) { content, handled, err := b.readUserFile(ctx, agentID, userID, fileName) if err != nil { return "", handled, err @@ -254,7 +254,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // USER.md writes are allowed so the bot can complete the first-run ritual. if strings.HasPrefix(userID, "group:") && protectedFileSet[fileName] { skipCheck := false - if fileName == "USER.md" && b.hasBootstrapFile(ctx, agentID, userID) { + if fileName == bootstrap.UserFile && b.hasBootstrapFile(ctx, agentID, userID) { skipCheck = true // onboarding in progress — allow USER.md write } if !skipCheck { @@ -277,7 +277,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // BOOTSTRAP.md deletion: empty content = first-run completed → delete row. // Must come BEFORE the predefined write block so bootstrap completion works // for both open and predefined agents. - if fileName == "BOOTSTRAP.md" && content == "" && userID != "" { + if fileName == bootstrap.BootstrapFile && content == "" && userID != "" { err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName) if err == nil { b.invalidateUser(agentID, userID) @@ -287,7 +287,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // Predefined agent: block writes to shared files (only USER.md allowed per-user). // This prevents any user from modifying the agent's identity/behavior via chat. - if agentType == "predefined" && fileName != "USER.md" { + if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile { return true, fmt.Errorf( "this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+ "Only the agent owner can edit it from the management dashboard.", @@ -296,7 +296,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st } // Open agent: all files per-user - if agentType == "open" && userID != "" { + if agentType == store.AgentTypeOpen && userID != "" { err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content) if err == nil { b.invalidateUser(agentID, userID) @@ -305,7 +305,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st } // Predefined agent: only USER.md per-user - if agentType == "predefined" && userID != "" && fileName == "USER.md" { + if agentType == store.AgentTypePredefined && userID != "" && fileName == bootstrap.UserFile { err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content) if err == nil { b.invalidateUser(agentID, userID) @@ -325,7 +325,7 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // Used by the agent loop to dynamically resolve context files for system prompt. func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile { // Open agent: all files from user_context_files - if agentType == "open" && userID != "" { + if agentType == store.AgentTypeOpen && userID != "" { files, err := b.agentStore.GetUserContextFiles(ctx, agentID, userID) if err != nil { return nil @@ -347,7 +347,7 @@ func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID u } // Predefined agent: agent files + override USER.md from user - if agentType == "predefined" && userID != "" { + if agentType == store.AgentTypePredefined && userID != "" { agentFiles, err := b.agentStore.GetAgentContextFiles(ctx, agentID) if err != nil { return nil @@ -440,7 +440,7 @@ func (b *ContextFileInterceptor) InvalidateAll() { // indicating the user is still in onboarding. Used to exempt USER.md writes // from group permission checks during the first-run ritual. func (b *ContextFileInterceptor) hasBootstrapFile(ctx context.Context, agentID uuid.UUID, userID string) bool { - content, _, err := b.readUserFile(ctx, agentID, userID, "BOOTSTRAP.md") + content, _, err := b.readUserFile(ctx, agentID, userID, bootstrap.BootstrapFile) return err == nil && content != "" } diff --git a/internal/tools/delegate.go b/internal/tools/delegate.go new file mode 100644 index 00000000..ca4c21f0 --- /dev/null +++ b/internal/tools/delegate.go @@ -0,0 +1,547 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "regexp" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// teamTaskIDRe extracts a team task UUID from delegate messages. +// Matches patterns like "task id 019c953d-06b3-..." or "task_id: 019c953d-06b3-..." +var teamTaskIDRe = regexp.MustCompile(`task[_ ]id[:\s]+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`) + +const defaultMaxDelegationLoad = 5 + +// DelegationTask tracks an active delegation for concurrency control and cancellation. +type DelegationTask struct { + ID string `json:"id"` + SourceAgentID uuid.UUID `json:"source_agent_id"` + SourceAgentKey string `json:"source_agent_key"` + TargetAgentID uuid.UUID `json:"target_agent_id"` + TargetAgentKey string `json:"target_agent_key"` + UserID string `json:"user_id"` + Task string `json:"task"` + Status string `json:"status"` // "running", "completed", "failed", "cancelled" + Mode string `json:"mode"` // "sync" or "async" + SessionKey string `json:"session_key"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + // Origin metadata for async announce routing + OriginChannel string `json:"-"` + OriginChatID string `json:"-"` + OriginPeerKind string `json:"-"` + + // Trace context for announce linking (same pattern as SubagentTask) + OriginTraceID uuid.UUID `json:"-"` + OriginRootSpanID uuid.UUID `json:"-"` + + // Team task auto-completion + TeamTaskID uuid.UUID `json:"-"` + + cancelFunc context.CancelFunc `json:"-"` +} + +// DelegateOpts configures a single delegation call. +type DelegateOpts struct { + TargetAgentKey string + Task string + Context string // optional extra context + Mode string // "sync" (default) or "async" + TeamTaskID uuid.UUID // optional: auto-complete this team task on success +} + +// DelegateRunRequest is the request passed to the AgentRunFunc callback. +// Mirrors agent.RunRequest without importing the agent package (avoids import cycle). +type DelegateRunRequest struct { + SessionKey string + Message string + UserID string + Channel string + ChatID string + PeerKind string + RunID string + Stream bool + ExtraSystemPrompt string +} + +// DelegateRunResult is the result from AgentRunFunc. +type DelegateRunResult struct { + Content string + Iterations int +} + +// AgentRunFunc runs an agent by key with the given request. +// This callback is injected from the cmd layer to avoid tools→agent import cycle. +type AgentRunFunc func(ctx context.Context, agentKey string, req DelegateRunRequest) (*DelegateRunResult, error) + +// DelegateResult is the outcome of a delegation. +type DelegateResult struct { + Content string + Iterations int + DelegationID string // for async: the delegation ID to track/cancel +} + +// linkSettings holds per-user restriction rules from agent_links.settings JSONB. +// NOTE: This is NOT the same as other_config.description (summoning prompt). +type linkSettings struct { + RequireRole string `json:"require_role"` + UserAllow []string `json:"user_allow"` + UserDeny []string `json:"user_deny"` +} + +// DelegateManager manages inter-agent delegation lifecycle. +// Similar to SubagentManager but delegates to fully-configured named agents. +type DelegateManager struct { + runAgent AgentRunFunc + linkStore store.AgentLinkStore + agentStore store.AgentStore + teamStore store.TeamStore // optional: enables auto-complete of team tasks + sessionStore store.SessionStore // optional: enables session cleanup + msgBus *bus.MessageBus // for event broadcast + async announce (PublishInbound) + + active sync.Map // delegationID → *DelegationTask + completedMu sync.Mutex + completedSessions []string // session keys pending cleanup +} + +// NewDelegateManager creates a new delegation manager. +func NewDelegateManager( + runAgent AgentRunFunc, + linkStore store.AgentLinkStore, + agentStore store.AgentStore, + msgBus *bus.MessageBus, +) *DelegateManager { + return &DelegateManager{ + runAgent: runAgent, + linkStore: linkStore, + agentStore: agentStore, + msgBus: msgBus, + } +} + +// SetTeamStore enables auto-completion of team tasks on delegation success. +func (dm *DelegateManager) SetTeamStore(ts store.TeamStore) { + dm.teamStore = ts +} + +// SetSessionStore enables session cleanup after team tasks complete. +func (dm *DelegateManager) SetSessionStore(ss store.SessionStore) { + dm.sessionStore = ss +} + +// Delegate executes a synchronous delegation to another agent. +func (dm *DelegateManager) Delegate(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { + task, _, err := dm.prepareDelegation(ctx, opts, "sync") + if err != nil { + return nil, err + } + + dm.active.Store(task.ID, task) + defer func() { + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(task.ID) + }() + + message := buildDelegateMessage(opts) + dm.emitEvent("delegation.started", task) + slog.Info("delegation started", "id", task.ID, "target", opts.TargetAgentKey, "mode", "sync") + + // Propagate parent trace ID so the delegate trace links back + delegateCtx := ctx + if parentTraceID := tracing.TraceIDFromContext(ctx); parentTraceID != uuid.Nil { + delegateCtx = tracing.WithDelegateParentTraceID(ctx, parentTraceID) + } + + result, err := dm.runAgent(delegateCtx, opts.TargetAgentKey, dm.buildRunRequest(task, message)) + if err != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + return nil, fmt.Errorf("delegation to %q failed: %w", opts.TargetAgentKey, err) + } + + task.Status = "completed" + dm.emitEvent("delegation.completed", task) + dm.trackCompleted(task) + dm.autoCompleteTeamTask(task, result.Content) + slog.Info("delegation completed", "id", task.ID, "target", opts.TargetAgentKey, "iterations", result.Iterations) + + return &DelegateResult{Content: result.Content, Iterations: result.Iterations, DelegationID: task.ID}, nil +} + +// DelegateAsync spawns a delegation in the background and announces the result back. +func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { + task, _, err := dm.prepareDelegation(ctx, opts, "async") + if err != nil { + return nil, err + } + + taskCtx, taskCancel := context.WithCancel(context.Background()) + task.cancelFunc = taskCancel + dm.active.Store(task.ID, task) + + // Capture parent trace ID before goroutine (ctx.Background() loses it) + parentTraceID := tracing.TraceIDFromContext(ctx) + if parentTraceID != uuid.Nil { + taskCtx = tracing.WithDelegateParentTraceID(taskCtx, parentTraceID) + } + + message := buildDelegateMessage(opts) + dm.emitEvent("delegation.started", task) + slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey) + + runReq := dm.buildRunRequest(task, message) + + go func() { + defer func() { + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(task.ID) + }() + + result, runErr := dm.runAgent(taskCtx, opts.TargetAgentKey, runReq) + + // Announce result to parent via message bus + if dm.msgBus != nil && task.OriginChannel != "" { + elapsed := time.Since(task.CreatedAt) + dm.msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("delegate:%s", task.ID), + ChatID: task.OriginChatID, + Content: formatDelegateAnnounce(task, result, runErr, elapsed), + UserID: task.UserID, + Metadata: map[string]string{ + "origin_channel": task.OriginChannel, + "origin_peer_kind": task.OriginPeerKind, + "parent_agent": task.SourceAgentKey, + "delegation_id": task.ID, + "target_agent": task.TargetAgentKey, + "origin_trace_id": task.OriginTraceID.String(), + "origin_root_span_id": task.OriginRootSpanID.String(), + }, + }) + } + + if runErr != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + } else { + task.Status = "completed" + dm.emitEvent("delegation.completed", task) + dm.trackCompleted(task) + if result != nil { + dm.autoCompleteTeamTask(task, result.Content) + } + } + slog.Info("delegation finished (async)", "id", task.ID, "target", task.TargetAgentKey, "status", task.Status) + }() + + return &DelegateResult{DelegationID: task.ID}, nil +} + +// Cancel cancels a running delegation by ID. +func (dm *DelegateManager) Cancel(delegationID string) bool { + val, ok := dm.active.Load(delegationID) + if !ok { + return false + } + task := val.(*DelegationTask) + if task.cancelFunc != nil { + task.cancelFunc() + } + task.Status = "cancelled" + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(delegationID) + dm.emitEvent("delegation.cancelled", task) + slog.Info("delegation cancelled", "id", delegationID, "target", task.TargetAgentKey) + return true +} + +// ListActive returns all active delegations for a source agent. +func (dm *DelegateManager) ListActive(sourceAgentID uuid.UUID) []*DelegationTask { + var tasks []*DelegationTask + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.SourceAgentID == sourceAgentID && t.Status == "running" { + tasks = append(tasks, t) + } + return true + }) + return tasks +} + +// ActiveCountForLink counts running delegations for a specific source→target pair. +func (dm *DelegateManager) ActiveCountForLink(sourceID, targetID uuid.UUID) int { + count := 0 + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.SourceAgentID == sourceID && t.TargetAgentID == targetID && t.Status == "running" { + count++ + } + return true + }) + return count +} + +// ActiveCountForTarget counts running delegations targeting a specific agent from all sources. +func (dm *DelegateManager) ActiveCountForTarget(targetID uuid.UUID) int { + count := 0 + dm.active.Range(func(_, val any) bool { + t := val.(*DelegationTask) + if t.TargetAgentID == targetID && t.Status == "running" { + count++ + } + return true + }) + return count +} + +// --- internal helpers --- + +func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateOpts, mode string) (*DelegationTask, *store.AgentLinkData, error) { + sourceAgentID := store.AgentIDFromContext(ctx) + if sourceAgentID == uuid.Nil { + return nil, nil, fmt.Errorf("delegation requires managed mode (no agent ID in context)") + } + + sourceAgent, err := dm.agentStore.GetByID(ctx, sourceAgentID) + if err != nil { + return nil, nil, fmt.Errorf("source agent not found: %w", err) + } + + targetAgent, err := dm.agentStore.GetByKey(ctx, opts.TargetAgentKey) + if err != nil { + return nil, nil, fmt.Errorf("target agent %q not found", opts.TargetAgentKey) + } + + link, err := dm.linkStore.GetLinkBetween(ctx, sourceAgentID, targetAgent.ID) + if err != nil { + return nil, nil, fmt.Errorf("failed to check delegation permission: %w", err) + } + if link == nil { + return nil, nil, fmt.Errorf("no delegation link from this agent to %q. Available targets are listed in AGENTS.md", opts.TargetAgentKey) + } + + userID := store.UserIDFromContext(ctx) + if err := checkUserPermission(link.Settings, userID); err != nil { + return nil, nil, err + } + + linkCount := dm.ActiveCountForLink(sourceAgentID, targetAgent.ID) + if link.MaxConcurrent > 0 && linkCount >= link.MaxConcurrent { + return nil, nil, fmt.Errorf("delegation link to %q is at capacity (%d/%d active). Try again later or handle the task yourself", + opts.TargetAgentKey, linkCount, link.MaxConcurrent) + } + + targetCount := dm.ActiveCountForTarget(targetAgent.ID) + maxLoad := parseMaxDelegationLoad(targetAgent.OtherConfig) + if targetCount >= maxLoad { + return nil, nil, fmt.Errorf("agent %q is at capacity (%d/%d active delegations). Either wait and retry, use a different agent, or handle the task yourself", + opts.TargetAgentKey, targetCount, maxLoad) + } + + channel := ToolChannelFromCtx(ctx) + chatID := ToolChatIDFromCtx(ctx) + peerKind := ToolPeerKindFromCtx(ctx) + + delegationID := uuid.NewString()[:12] + task := &DelegationTask{ + ID: delegationID, + SourceAgentID: sourceAgentID, + SourceAgentKey: sourceAgent.AgentKey, + TargetAgentID: targetAgent.ID, + TargetAgentKey: opts.TargetAgentKey, + UserID: userID, + Task: opts.Task, + Status: "running", + Mode: mode, + SessionKey: fmt.Sprintf("delegate:%s:%s:%s", + sourceAgentID.String()[:8], opts.TargetAgentKey, delegationID), + CreatedAt: time.Now(), + OriginChannel: channel, + OriginChatID: chatID, + OriginPeerKind: peerKind, + OriginTraceID: tracing.TraceIDFromContext(ctx), + OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), + TeamTaskID: resolveTeamTaskID(opts), + } + + return task, link, nil +} + +func buildDelegateMessage(opts DelegateOpts) string { + if opts.Context != "" { + return fmt.Sprintf("[Additional Context]\n%s\n\n[Task]\n%s", opts.Context, opts.Task) + } + return opts.Task +} + +func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string) DelegateRunRequest { + return DelegateRunRequest{ + SessionKey: task.SessionKey, + Message: message, + UserID: task.UserID, + Channel: "delegate", + ChatID: task.OriginChatID, + PeerKind: task.OriginPeerKind, + RunID: fmt.Sprintf("delegate-%s", task.ID), + Stream: false, + ExtraSystemPrompt: "[Delegation Context]\nYou are handling a delegated task from another agent.\n" + + "- Focus exclusively on the delegated task below.\n" + + "- Your complete response will be returned to the requesting agent.\n" + + "- Do NOT try to communicate with the end user directly.\n" + + "- Do NOT use your persona name or self-references (e.g. do not say your name). Write factual, neutral content.\n" + + "- Be concise and deliver actionable results.", + } +} + +func checkUserPermission(settings json.RawMessage, userID string) error { + if len(settings) == 0 || string(settings) == "{}" { + return nil + } + var s linkSettings + if json.Unmarshal(settings, &s) != nil { + return nil // malformed = fail open + } + for _, denied := range s.UserDeny { + if denied == userID { + return fmt.Errorf("you are not authorized to use this delegation link") + } + } + if len(s.UserAllow) > 0 { + for _, allowed := range s.UserAllow { + if allowed == userID { + return nil + } + } + return fmt.Errorf("you are not authorized to use this delegation link") + } + return nil +} + +func parseMaxDelegationLoad(otherConfig json.RawMessage) int { + if len(otherConfig) == 0 { + return defaultMaxDelegationLoad + } + var cfg struct { + MaxDelegationLoad int `json:"max_delegation_load"` + } + if json.Unmarshal(otherConfig, &cfg) != nil || cfg.MaxDelegationLoad <= 0 { + return defaultMaxDelegationLoad + } + return cfg.MaxDelegationLoad +} + +// trackCompleted records a delegate session key for deferred cleanup. +func (dm *DelegateManager) trackCompleted(task *DelegationTask) { + if dm.sessionStore == nil { + return + } + dm.completedMu.Lock() + dm.completedSessions = append(dm.completedSessions, task.SessionKey) + dm.completedMu.Unlock() +} + +// flushCompletedSessions deletes all tracked delegate sessions. +func (dm *DelegateManager) flushCompletedSessions() { + if dm.sessionStore == nil { + return + } + dm.completedMu.Lock() + sessions := dm.completedSessions + dm.completedSessions = nil + dm.completedMu.Unlock() + + for _, key := range sessions { + if err := dm.sessionStore.Delete(key); err != nil { + slog.Warn("delegate: session cleanup failed", "session", key, "error", err) + } + } + if len(sessions) > 0 { + slog.Info("delegate: cleaned up sessions", "count", len(sessions)) + } +} + +// autoCompleteTeamTask attempts to claim+complete the associated team task. +// Called after a delegation finishes successfully. Errors are logged but not fatal. +// On success, flushes all tracked delegate sessions (task done = context no longer needed). +func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultContent string) { + if dm.teamStore == nil || task.TeamTaskID == uuid.Nil { + return + } + // Truncate result for storage (keep first 500 runes to avoid splitting multi-byte UTF-8) + runes := []rune(resultContent) + if len(runes) > 500 { + runes = runes[:500] + } + summary := string(runes) + if len(runes) < len([]rune(resultContent)) { + summary += "..." + } + _ = dm.teamStore.ClaimTask(context.Background(), task.TeamTaskID, task.TargetAgentID) + if err := dm.teamStore.CompleteTask(context.Background(), task.TeamTaskID, summary); err != nil { + slog.Warn("delegate: failed to auto-complete team task", + "task_id", task.TeamTaskID, "delegation_id", task.ID, "error", err) + } else { + slog.Info("delegate: auto-completed team task", + "task_id", task.TeamTaskID, "delegation_id", task.ID) + // Task done — flush delegate sessions + dm.flushCompletedSessions() + } +} + +// resolveTeamTaskID determines the team task ID from explicit opts or regex fallback. +func resolveTeamTaskID(opts DelegateOpts) uuid.UUID { + if opts.TeamTaskID != uuid.Nil { + return opts.TeamTaskID + } + // Regex fallback: extract from task message + if match := teamTaskIDRe.FindStringSubmatch(opts.Task); len(match) >= 2 { + if id, err := uuid.Parse(match[1]); err == nil { + return id + } + } + return uuid.Nil +} + +func (dm *DelegateManager) emitEvent(name string, task *DelegationTask) { + if dm.msgBus == nil { + return + } + dm.msgBus.Broadcast(bus.Event{ + Name: name, + Payload: map[string]string{ + "delegation_id": task.ID, + "source_agent": task.SourceAgentID.String(), + "target_agent": task.TargetAgentKey, + "user_id": task.UserID, + "mode": task.Mode, + }, + }) +} + +func formatDelegateAnnounce(task *DelegationTask, result *DelegateRunResult, err error, elapsed time.Duration) string { + if err != nil { + return fmt.Sprintf( + "[System Message] Delegation to agent %q failed.\n\nError: %s\n\nStats: runtime %s\n\n"+ + "Handle the task yourself or try a different agent.", + task.TargetAgentKey, err.Error(), elapsed.Round(time.Millisecond)) + } + return fmt.Sprintf( + "[System Message] Delegation to agent %q completed.\n\nResult:\n%s\n\nStats: runtime %s, iterations %d\n\n"+ + "Convert the result above into your normal assistant voice and send that user-facing update now. "+ + "Keep internal details private. Reply ONLY: NO_REPLY if this exact result was already delivered to the user.", + task.TargetAgentKey, result.Content, elapsed.Round(time.Millisecond), result.Iterations) +} diff --git a/internal/tools/delegate_search_tool.go b/internal/tools/delegate_search_tool.go new file mode 100644 index 00000000..c12a4381 --- /dev/null +++ b/internal/tools/delegate_search_tool.go @@ -0,0 +1,180 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// DelegateSearchTool performs hybrid FTS + semantic search over delegation targets. +// Used when an agent has too many targets for static AGENTS.md (>15). +type DelegateSearchTool struct { + linkStore store.AgentLinkStore + embProvider store.EmbeddingProvider // optional: enables semantic search +} + +func NewDelegateSearchTool(linkStore store.AgentLinkStore, embProvider store.EmbeddingProvider) *DelegateSearchTool { + return &DelegateSearchTool{linkStore: linkStore, embProvider: embProvider} +} + +func (t *DelegateSearchTool) Name() string { return "delegate_search" } + +func (t *DelegateSearchTool) Description() string { + return "Search for available delegation target agents by keyword or description. Use this to find the right agent to delegate a task to." +} + +func (t *DelegateSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search keywords to find relevant agents", + }, + "max_results": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of results (default 5)", + }, + }, + "required": []string{"query"}, + } +} + +func (t *DelegateSearchTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query parameter is required") + } + + maxResults := 5 + if mr, ok := args["max_results"].(float64); ok && int(mr) > 0 { + maxResults = int(mr) + } + + sourceAgentID := store.AgentIDFromContext(ctx) + + // FTS search (always available) + ftsResults, err := t.linkStore.SearchDelegateTargets(ctx, sourceAgentID, query, maxResults*2) + if err != nil { + slog.Warn("delegate_search FTS failed", "error", err) + } + + // If embedding provider available, run hybrid search + var results []store.AgentLinkData + if t.embProvider != nil { + results = t.hybridSearch(ctx, sourceAgentID, query, ftsResults, maxResults) + } else { + if len(ftsResults) > maxResults { + ftsResults = ftsResults[:maxResults] + } + results = ftsResults + } + + slog.Info("delegate_search", "query", query, "results", len(results), "hybrid", t.embProvider != nil) + + if len(results) == 0 { + return NewResult(fmt.Sprintf("No delegation target agents found matching: %s", query)) + } + + type searchResult struct { + AgentKey string `json:"agent_key"` + DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` + } + var out []searchResult + for _, r := range results { + out = append(out, searchResult{ + AgentKey: r.TargetAgentKey, + DisplayName: r.TargetDisplayName, + Frontmatter: r.TargetDescription, + }) + } + + data, _ := json.MarshalIndent(map[string]interface{}{ + "agents": out, + "count": len(out), + }, "", " ") + + return NewResult(string(data) + + "\n\nUse `delegate(agent=\"\", task=\"your task\")` to delegate to one of these agents.") +} + +// hybridSearch merges FTS and embedding results with weighted scoring. +// BM25 weight 0.3, vector weight 0.7 (same as skill_search.go). +func (t *DelegateSearchTool) hybridSearch(ctx context.Context, sourceAgentID uuid.UUID, query string, ftsResults []store.AgentLinkData, maxResults int) []store.AgentLinkData { + // Generate query embedding + embeddings, err := t.embProvider.Embed(ctx, []string{query}) + if err != nil || len(embeddings) == 0 || len(embeddings[0]) == 0 { + slog.Warn("delegate_search embedding failed, falling back to FTS", "error", err) + if len(ftsResults) > maxResults { + ftsResults = ftsResults[:maxResults] + } + return ftsResults + } + + // Vector search + vecResults, err := t.linkStore.SearchDelegateTargetsByEmbedding(ctx, sourceAgentID, embeddings[0], maxResults*2) + if err != nil { + slog.Warn("delegate_search vector search failed, falling back to FTS", "error", err) + if len(ftsResults) > maxResults { + ftsResults = ftsResults[:maxResults] + } + return ftsResults + } + + // Merge: normalize weights when one channel has no results + textW, vecW := 0.3, 0.7 + if len(ftsResults) == 0 && len(vecResults) > 0 { + textW, vecW = 0, 1.0 + } else if len(vecResults) == 0 && len(ftsResults) > 0 { + textW, vecW = 1.0, 0 + } + + // Deduplicate by agent key, accumulate scores + type merged struct { + link store.AgentLinkData + score float64 + } + seen := make(map[string]*merged) + + for i, r := range ftsResults { + // Simple position-based score for FTS (no ts_rank exposed in AgentLinkData) + normalizedScore := 1.0 - float64(i)/float64(len(ftsResults)+1) + if existing, ok := seen[r.TargetAgentKey]; ok { + existing.score += normalizedScore * textW + } else { + seen[r.TargetAgentKey] = &merged{link: r, score: normalizedScore * textW} + } + } + + for i, r := range vecResults { + normalizedScore := 1.0 - float64(i)/float64(len(vecResults)+1) + if existing, ok := seen[r.TargetAgentKey]; ok { + existing.score += normalizedScore * vecW + } else { + seen[r.TargetAgentKey] = &merged{link: r, score: normalizedScore * vecW} + } + } + + // Collect and sort by score descending + results := make([]store.AgentLinkData, 0, len(seen)) + scores := make(map[string]float64) + for key, m := range seen { + results = append(results, m.link) + scores[key] = m.score + } + sort.Slice(results, func(i, j int) bool { + return scores[results[i].TargetAgentKey] > scores[results[j].TargetAgentKey] + }) + + if len(results) > maxResults { + results = results[:maxResults] + } + return results +} diff --git a/internal/tools/delegate_tool.go b/internal/tools/delegate_tool.go new file mode 100644 index 00000000..616268dc --- /dev/null +++ b/internal/tools/delegate_tool.go @@ -0,0 +1,161 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// DelegateTool is a thin wrapper around DelegateManager. +// Supports actions: delegate (default), cancel, list. +type DelegateTool struct { + manager *DelegateManager +} + +func NewDelegateTool(manager *DelegateManager) *DelegateTool { + return &DelegateTool{manager: manager} +} + +func (t *DelegateTool) Name() string { return "delegate" } + +func (t *DelegateTool) Description() string { + return "Delegate a task to another specialized agent, cancel a running delegation, or list active delegations. The target agent runs with its own identity, tools, and expertise. See AGENTS.md for available agents." +} + +func (t *DelegateTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "'delegate' (default), 'cancel', or 'list'", + }, + "agent": map[string]interface{}{ + "type": "string", + "description": "Target agent key (required for action=delegate)", + }, + "task": map[string]interface{}{ + "type": "string", + "description": "Task description (required for action=delegate)", + }, + "context": map[string]interface{}{ + "type": "string", + "description": "Optional additional context for the target agent", + }, + "mode": map[string]interface{}{ + "type": "string", + "description": "'sync' (default, blocks until done) or 'async' (returns immediately, result announced later)", + }, + "delegation_id": map[string]interface{}{ + "type": "string", + "description": "Delegation ID to cancel (required for action=cancel)", + }, + "team_task_id": map[string]interface{}{ + "type": "string", + "description": "Team task ID to auto-complete when delegation finishes (optional, for team workflows)", + }, + }, + "required": []string{}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + action, _ := args["action"].(string) + if action == "" { + action = "delegate" + } + + switch action { + case "delegate": + return t.executeDelegation(ctx, args) + case "cancel": + return t.executeCancel(args) + case "list": + return t.executeList(ctx) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (use delegate, cancel, or list)", action)) + } +} + +func (t *DelegateTool) executeDelegation(ctx context.Context, args map[string]interface{}) *Result { + agentKey, _ := args["agent"].(string) + if agentKey == "" { + return ErrorResult("agent parameter is required for delegation") + } + task, _ := args["task"].(string) + if task == "" { + return ErrorResult("task parameter is required for delegation") + } + + extraContext, _ := args["context"].(string) + mode, _ := args["mode"].(string) + if mode == "" { + mode = "sync" + } + + var teamTaskID uuid.UUID + if ttID, _ := args["team_task_id"].(string); ttID != "" { + teamTaskID, _ = uuid.Parse(ttID) + } + + opts := DelegateOpts{ + TargetAgentKey: agentKey, + Task: task, + Context: extraContext, + Mode: mode, + TeamTaskID: teamTaskID, + } + + if mode == "async" { + result, err := t.manager.DelegateAsync(ctx, opts) + if err != nil { + return ErrorResult(err.Error()) + } + forLLM := fmt.Sprintf(`{"status":"accepted","delegation_id":%q,"target":%q,"mode":"async"} +Delegated to %q (async, id=%s). The result will be announced automatically when done — do NOT wait or poll. +Briefly tell the user what you've delegated and to whom. Be friendly and natural.`, + result.DelegationID, agentKey, agentKey, result.DelegationID) + return AsyncResult(forLLM) + } + + // Sync (default) + result, err := t.manager.Delegate(ctx, opts) + if err != nil { + return ErrorResult(err.Error()) + } + forLLM := fmt.Sprintf( + "Delegation to %q completed (%d iterations).\n\nResult:\n%s\n\n"+ + "Present the information above to the user in YOUR OWN voice and persona. "+ + "Do NOT adopt the delegate agent's personality, tone, or self-references. "+ + "Rephrase and summarize naturally as yourself.", + agentKey, result.Iterations, result.Content) + return NewResult(forLLM) +} + +func (t *DelegateTool) executeCancel(args map[string]interface{}) *Result { + delegationID, _ := args["delegation_id"].(string) + if delegationID == "" { + return ErrorResult("delegation_id is required for cancel action") + } + if t.manager.Cancel(delegationID) { + return NewResult(fmt.Sprintf("Delegation %s cancelled.", delegationID)) + } + return ErrorResult(fmt.Sprintf("delegation %s not found or already completed", delegationID)) +} + +func (t *DelegateTool) executeList(ctx context.Context) *Result { + sourceAgentID := store.AgentIDFromContext(ctx) + tasks := t.manager.ListActive(sourceAgentID) + if len(tasks) == 0 { + return SilentResult(`{"delegations":[],"count":0}`) + } + out, _ := json.Marshal(map[string]interface{}{ + "delegations": tasks, + "count": len(tasks), + }) + return SilentResult(string(out)) +} diff --git a/internal/tools/memory_interceptor.go b/internal/tools/memory_interceptor.go index e5954cac..2bfa2e6f 100644 --- a/internal/tools/memory_interceptor.go +++ b/internal/tools/memory_interceptor.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -19,7 +20,7 @@ func isMemoryPath(path, workspace string) bool { // Root-level MEMORY.md or memory.md dir := filepath.Dir(clean) - if (dir == "." || dir == "/" || dir == "") && (base == "MEMORY.md" || base == "memory.md") { + if (dir == "." || dir == "/" || dir == "") && (base == bootstrap.MemoryFile || base == bootstrap.MemoryAltFile) { return true } @@ -31,7 +32,7 @@ func isMemoryPath(path, workspace string) bool { // Absolute path at workspace root or under workspace/memory/ if workspace != "" && filepath.IsAbs(clean) { cleanWS := filepath.Clean(workspace) - if filepath.Dir(clean) == cleanWS && (base == "MEMORY.md" || base == "memory.md") { + if filepath.Dir(clean) == cleanWS && (base == bootstrap.MemoryFile || base == bootstrap.MemoryAltFile) { return true } memDir := filepath.Join(cleanWS, "memory") diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 6eb5c4a3..a352b2b9 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -45,6 +45,14 @@ func DefaultSubagentConfig() SubagentConfig { } } +// Subagent task status constants. +const ( + TaskStatusRunning = "running" + TaskStatusCompleted = "completed" + TaskStatusFailed = "failed" + TaskStatusCancelled = "cancelled" +) + // SubagentTask tracks a running or completed subagent. type SubagentTask struct { ID string `json:"id"` @@ -110,7 +118,7 @@ func (sm *SubagentManager) CountRunningForParent(parentID string) int { defer sm.mu.RUnlock() count := 0 for _, t := range sm.tasks { - if t.ParentID == parentID && t.Status == "running" { + if t.ParentID == parentID && t.Status == TaskStatusRunning { count++ } } @@ -160,7 +168,7 @@ func (sm *SubagentManager) Spawn( // Check concurrent limit running := 0 for _, t := range sm.tasks { - if t.Status == "running" { + if t.Status == TaskStatusRunning { running++ } } @@ -258,7 +266,7 @@ func (sm *SubagentManager) RunSync( iterations := sm.executeTask(ctx, subTask) - if subTask.Status == "failed" { + if subTask.Status == TaskStatusFailed { return subTask.Result, iterations, fmt.Errorf("subagent failed: %s", subTask.Result) } @@ -375,7 +383,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) if ctx.Err() != nil { sm.mu.Lock() - task.Status = "cancelled" + task.Status = TaskStatusCancelled task.Result = "cancelled before execution" sm.mu.Unlock() return 0 @@ -413,7 +421,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) if ctx.Err() != nil { sm.mu.Lock() - task.Status = "cancelled" + task.Status = TaskStatusCancelled task.Result = "cancelled during execution" sm.mu.Unlock() return iteration @@ -435,7 +443,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) if err != nil { sm.mu.Lock() - task.Status = "failed" + task.Status = TaskStatusFailed task.Result = fmt.Sprintf("LLM error at iteration %d: %v", iteration, err) sm.mu.Unlock() slog.Warn("subagent LLM error", "id", task.ID, "iteration", iteration, "error", err) @@ -478,7 +486,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) if finalContent == "" { finalContent = "Task completed but no final response was generated." } - task.Status = "completed" + task.Status = TaskStatusCompleted task.Result = finalContent sm.mu.Unlock() diff --git a/internal/tools/subagent_control.go b/internal/tools/subagent_control.go index b42e4eef..ff0e47c2 100644 --- a/internal/tools/subagent_control.go +++ b/internal/tools/subagent_control.go @@ -16,7 +16,7 @@ func (sm *SubagentManager) scheduleArchive(taskID string, after time.Duration) { time.Sleep(after) sm.mu.Lock() defer sm.mu.Unlock() - if t, ok := sm.tasks[taskID]; ok && t.Status != "running" { + if t, ok := sm.tasks[taskID]; ok && t.Status != TaskStatusRunning { delete(sm.tasks, taskID) slog.Debug("subagent archived", "id", taskID) } @@ -53,7 +53,7 @@ func (sm *SubagentManager) CancelTask(id string) bool { if id == "all" { cancelled := false for _, t := range sm.tasks { - if t.Status == "running" { + if t.Status == TaskStatusRunning { sm.cancelTaskLocked(t) cancelled = true } @@ -64,7 +64,7 @@ func (sm *SubagentManager) CancelTask(id string) bool { if id == "last" { var latest *SubagentTask for _, t := range sm.tasks { - if t.Status == "running" { + if t.Status == TaskStatusRunning { if latest == nil || t.CreatedAt > latest.CreatedAt { latest = t } @@ -78,7 +78,7 @@ func (sm *SubagentManager) CancelTask(id string) bool { } t, ok := sm.tasks[id] - if !ok || t.Status != "running" { + if !ok || t.Status != TaskStatusRunning { return false } sm.cancelTaskLocked(t) @@ -91,7 +91,7 @@ func (sm *SubagentManager) CancelTasksForParent(parentID string) int { defer sm.mu.Unlock() count := 0 for _, t := range sm.tasks { - if t.ParentID == parentID && t.Status == "running" { + if t.ParentID == parentID && t.Status == TaskStatusRunning { sm.cancelTaskLocked(t) count++ } @@ -102,7 +102,7 @@ func (sm *SubagentManager) CancelTasksForParent(parentID string) int { // cancelTaskLocked sets a task to cancelled and fires its context cancel. // Must be called with sm.mu held. func (sm *SubagentManager) cancelTaskLocked(t *SubagentTask) { - t.Status = "cancelled" + t.Status = TaskStatusCancelled t.Result = "cancelled by user" t.CompletedAt = time.Now().UnixMilli() if t.cancelFunc != nil { @@ -123,7 +123,7 @@ func (sm *SubagentManager) Steer( sm.mu.Unlock() return "", fmt.Errorf("subagent %q not found", taskID) } - if t.Status != "running" { + if t.Status != TaskStatusRunning { sm.mu.Unlock() return "", fmt.Errorf("subagent %q is not running (status=%s)", taskID, t.Status) } diff --git a/internal/tools/subagent_tracing.go b/internal/tools/subagent_tracing.go index fcf1e90f..1d95a3b0 100644 --- a/internal/tools/subagent_tracing.go +++ b/internal/tools/subagent_tracing.go @@ -25,15 +25,15 @@ func (sm *SubagentManager) emitLLMSpan(ctx context.Context, start time.Time, ite now := time.Now().UTC() span := store.SpanData{ TraceID: traceID, - SpanType: "llm_call", + SpanType: store.SpanTypeLLMCall, Name: fmt.Sprintf("%s/%s #%d", sm.provider.Name(), model, iteration), StartTime: start, EndTime: &now, DurationMS: int(now.Sub(start).Milliseconds()), Model: model, Provider: sm.provider.Name(), - Status: "completed", - Level: "DEFAULT", + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, CreatedAt: now, } if parentID := tracing.ParentSpanIDFromContext(ctx); parentID != uuid.Nil { @@ -48,7 +48,7 @@ func (sm *SubagentManager) emitLLMSpan(ctx context.Context, start time.Time, ite } if callErr != nil { - span.Status = "error" + span.Status = store.SpanStatusError span.Error = callErr.Error() } else if resp != nil { if resp.Usage != nil { @@ -72,7 +72,7 @@ func (sm *SubagentManager) emitToolSpan(ctx context.Context, start time.Time, to now := time.Now().UTC() span := store.SpanData{ TraceID: traceID, - SpanType: "tool_call", + SpanType: store.SpanTypeToolCall, Name: toolName, StartTime: start, EndTime: &now, @@ -81,15 +81,15 @@ func (sm *SubagentManager) emitToolSpan(ctx context.Context, start time.Time, to ToolCallID: toolCallID, InputPreview: truncate(input, 500), OutputPreview: truncate(output, 500), - Status: "completed", - Level: "DEFAULT", + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, CreatedAt: now, } if parentID := tracing.ParentSpanIDFromContext(ctx); parentID != uuid.Nil { span.ParentSpanID = &parentID } if isError { - span.Status = "error" + span.Status = store.SpanStatusError span.Error = truncate(output, 200) } collector.EmitSpan(span) @@ -111,7 +111,7 @@ func (sm *SubagentManager) emitSubagentSpan(ctx context.Context, spanID uuid.UUI span := store.SpanData{ ID: spanID, TraceID: traceID, - SpanType: "agent", + SpanType: store.SpanTypeAgent, Name: fmt.Sprintf("subagent:%s", task.Label), StartTime: start, EndTime: &now, @@ -119,15 +119,15 @@ func (sm *SubagentManager) emitSubagentSpan(ctx context.Context, spanID uuid.UUI Model: model, Provider: sm.provider.Name(), OutputPreview: truncate(output, 500), - Status: "completed", - Level: "DEFAULT", + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, CreatedAt: now, } if parentSpanID != uuid.Nil { span.ParentSpanID = &parentSpanID } - if task.Status == "failed" || task.Status == "cancelled" { - span.Status = "error" + if task.Status == TaskStatusFailed || task.Status == TaskStatusCancelled { + span.Status = store.SpanStatusError span.Error = truncate(task.Result, 200) } collector.EmitSpan(span) diff --git a/internal/tools/team_message_tool.go b/internal/tools/team_message_tool.go new file mode 100644 index 00000000..d4e1f764 --- /dev/null +++ b/internal/tools/team_message_tool.go @@ -0,0 +1,190 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TeamMessageTool exposes the team mailbox to agents. +// Actions: send, broadcast, read. +type TeamMessageTool struct { + manager *TeamToolManager +} + +func NewTeamMessageTool(manager *TeamToolManager) *TeamMessageTool { + return &TeamMessageTool{manager: manager} +} + +func (t *TeamMessageTool) Name() string { return "team_message" } + +func (t *TeamMessageTool) Description() string { + return "Send and receive messages within your team. Actions: send (direct message to a teammate), broadcast (message all teammates), read (check unread messages). See TEAM.md for your teammates." +} + +func (t *TeamMessageTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "'send', 'broadcast', or 'read'", + }, + "to": map[string]interface{}{ + "type": "string", + "description": "Target agent key (required for action=send)", + }, + "text": map[string]interface{}{ + "type": "string", + "description": "Message content (required for action=send and action=broadcast)", + }, + }, + "required": []string{"action"}, + } +} + +func (t *TeamMessageTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + action, _ := args["action"].(string) + + switch action { + case "send": + return t.executeSend(ctx, args) + case "broadcast": + return t.executeBroadcast(ctx, args) + case "read": + return t.executeRead(ctx) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (use send, broadcast, or read)", action)) + } +} + +func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]interface{}) *Result { + team, agentID, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + toKey, _ := args["to"].(string) + if toKey == "" { + return ErrorResult("to parameter is required for send action") + } + text, _ := args["text"].(string) + if text == "" { + return ErrorResult("text parameter is required for send action") + } + + toAgentID, err := t.manager.resolveAgentByKey(toKey) + if err != nil { + return ErrorResult(err.Error()) + } + + // Persist to DB + msg := &store.TeamMessageData{ + TeamID: team.ID, + FromAgentID: agentID, + ToAgentID: &toAgentID, + Content: text, + MessageType: store.TeamMessageTypeChat, + } + if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil { + return ErrorResult("failed to send message: " + err.Error()) + } + + // Real-time delivery via message bus + fromKey := t.manager.agentKeyFromID(ctx, agentID) + t.publishTeammateMessage(fromKey, toKey, text, ctx) + + return NewResult(fmt.Sprintf("Message sent to %s.", toKey)) +} + +func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string]interface{}) *Result { + team, agentID, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + text, _ := args["text"].(string) + if text == "" { + return ErrorResult("text parameter is required for broadcast action") + } + + // Persist to DB (to_agent_id = NULL means broadcast) + msg := &store.TeamMessageData{ + TeamID: team.ID, + FromAgentID: agentID, + ToAgentID: nil, + Content: text, + MessageType: store.TeamMessageTypeBroadcast, + } + if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil { + return ErrorResult("failed to broadcast message: " + err.Error()) + } + + // Real-time delivery to all teammates via message bus + fromKey := t.manager.agentKeyFromID(ctx, agentID) + members, err := t.manager.teamStore.ListMembers(ctx, team.ID) + if err == nil { + for _, m := range members { + if m.AgentID == agentID { + continue // don't send to self + } + t.publishTeammateMessage(fromKey, m.AgentKey, text, ctx) + } + } + + return NewResult(fmt.Sprintf("Broadcast sent to all teammates.")) +} + +func (t *TeamMessageTool) executeRead(ctx context.Context) *Result { + team, agentID, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + messages, err := t.manager.teamStore.GetUnread(ctx, team.ID, agentID) + if err != nil { + return ErrorResult("failed to get unread messages: " + err.Error()) + } + + // Mark all as read + for _, msg := range messages { + _ = t.manager.teamStore.MarkRead(ctx, msg.ID) + } + + out, _ := json.Marshal(map[string]interface{}{ + "messages": messages, + "count": len(messages), + }) + return SilentResult(string(out)) +} + +// publishTeammateMessage sends a real-time notification via the message bus. +// Uses "teammate:{fromKey}" sender prefix so the consumer can route it. +func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, ctx context.Context) { + if t.manager.msgBus == nil { + return + } + + userID := store.UserIDFromContext(ctx) + chatID := ToolChatIDFromCtx(ctx) + originChannel := ToolChannelFromCtx(ctx) + originPeerKind := ToolPeerKindFromCtx(ctx) + + t.manager.msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("teammate:%s", fromKey), + ChatID: chatID, + Content: fmt.Sprintf("[Team message from %s]: %s", fromKey, text), + UserID: userID, + AgentID: toKey, + Metadata: map[string]string{ + "origin_channel": originChannel, + "origin_peer_kind": originPeerKind, + "from_agent": fromKey, + "to_agent": toKey, + }, + }) +} diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go new file mode 100644 index 00000000..88dd218d --- /dev/null +++ b/internal/tools/team_tasks_tool.go @@ -0,0 +1,203 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TeamTasksTool exposes the shared team task list to agents. +// Actions: list, create, claim, complete. +type TeamTasksTool struct { + manager *TeamToolManager +} + +func NewTeamTasksTool(manager *TeamToolManager) *TeamTasksTool { + return &TeamTasksTool{manager: manager} +} + +func (t *TeamTasksTool) Name() string { return "team_tasks" } + +func (t *TeamTasksTool) Description() string { + return "Manage the shared team task list. Actions: list (view all tasks), create (add a new task), claim (self-assign a pending task), complete (mark your task as done with a result). See TEAM.md for your team context." +} + +func (t *TeamTasksTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "'list', 'create', 'claim', or 'complete'", + }, + "subject": map[string]interface{}{ + "type": "string", + "description": "Task subject (required for action=create)", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "Task description (optional, for action=create)", + }, + "priority": map[string]interface{}{ + "type": "number", + "description": "Task priority, higher = more important (optional, for action=create, default 0)", + }, + "blocked_by": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Task IDs that must complete before this task can be claimed (optional, for action=create)", + }, + "task_id": map[string]interface{}{ + "type": "string", + "description": "Task ID (required for action=claim and action=complete)", + }, + "result": map[string]interface{}{ + "type": "string", + "description": "Task result summary (required for action=complete)", + }, + }, + "required": []string{"action"}, + } +} + +func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]interface{}) *Result { + action, _ := args["action"].(string) + + switch action { + case "list": + return t.executeList(ctx) + case "create": + return t.executeCreate(ctx, args) + case "claim": + return t.executeClaim(ctx, args) + case "complete": + return t.executeComplete(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (use list, create, claim, or complete)", action)) + } +} + +func (t *TeamTasksTool) executeList(ctx context.Context) *Result { + team, _, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority") + if err != nil { + return ErrorResult("failed to list tasks: " + err.Error()) + } + + out, _ := json.Marshal(map[string]interface{}{ + "tasks": tasks, + "count": len(tasks), + }) + return SilentResult(string(out)) +} + +func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]interface{}) *Result { + team, _, err := t.manager.resolveTeam(ctx) + if 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"].([]interface{}); ok { + for _, v := range raw { + if s, ok := v.(string); ok { + if id, err := uuid.Parse(s); err == nil { + blockedBy = append(blockedBy, id) + } + } + } + } + + status := store.TeamTaskStatusPending + if len(blockedBy) > 0 { + status = store.TeamTaskStatusBlocked + } + + task := &store.TeamTaskData{ + TeamID: team.ID, + Subject: subject, + Description: description, + Status: status, + BlockedBy: blockedBy, + Priority: priority, + } + + if err := t.manager.teamStore.CreateTask(ctx, task); err != nil { + return ErrorResult("failed to create task: " + err.Error()) + } + + return NewResult(fmt.Sprintf("Task created: %s (id=%s, status=%s)", subject, task.ID, status)) +} + +func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]interface{}) *Result { + _, agentID, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + taskIDStr, _ := args["task_id"].(string) + if taskIDStr == "" { + return ErrorResult("task_id is required for claim action") + } + taskID, err := uuid.Parse(taskIDStr) + if err != nil { + return ErrorResult("invalid task_id") + } + + if err := t.manager.teamStore.ClaimTask(ctx, taskID, agentID); err != nil { + return ErrorResult("failed to claim task: " + err.Error()) + } + + return NewResult(fmt.Sprintf("Task %s claimed successfully. It is now in progress.", taskIDStr)) +} + +func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]interface{}) *Result { + _, agentID, err := t.manager.resolveTeam(ctx) + if err != nil { + return ErrorResult(err.Error()) + } + + taskIDStr, _ := args["task_id"].(string) + if taskIDStr == "" { + return ErrorResult("task_id is required for complete action") + } + taskID, err := uuid.Parse(taskIDStr) + if err != nil { + return ErrorResult("invalid task_id") + } + + 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) + + if err := t.manager.teamStore.CompleteTask(ctx, taskID, result); err != nil { + return ErrorResult("failed to complete task: " + err.Error()) + } + + return NewResult(fmt.Sprintf("Task %s completed. Dependent tasks have been unblocked.", taskIDStr)) +} diff --git a/internal/tools/team_tool_manager.go b/internal/tools/team_tool_manager.go new file mode 100644 index 00000000..a1e3afa2 --- /dev/null +++ b/internal/tools/team_tool_manager.go @@ -0,0 +1,60 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TeamToolManager is the shared backend for team_tasks and team_message tools. +// It resolves the calling agent's team from context and provides access to +// the team store, agent store, and message bus. +type TeamToolManager struct { + teamStore store.TeamStore + agentStore store.AgentStore + msgBus *bus.MessageBus +} + +func NewTeamToolManager(teamStore store.TeamStore, agentStore store.AgentStore, msgBus *bus.MessageBus) *TeamToolManager { + return &TeamToolManager{teamStore: teamStore, agentStore: agentStore, msgBus: msgBus} +} + +// resolveTeam returns the team that the calling agent belongs to. +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 managed mode") + } + + team, err := m.teamStore.GetTeamForAgent(ctx, agentID) + if err != nil { + return nil, uuid.Nil, fmt.Errorf("failed to resolve team: %w", err) + } + if team == nil { + return nil, uuid.Nil, fmt.Errorf("this agent is not part of any team") + } + + return team, agentID, nil +} + +// resolveAgentByKey looks up an agent by key and returns its UUID. +func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) { + ag, err := m.agentStore.GetByKey(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.agentStore.GetByID(ctx, id) + if err != nil { + return id.String() + } + return ag.AgentKey +} diff --git a/internal/tracing/context.go b/internal/tracing/context.go index f29ba14c..5b696f8c 100644 --- a/internal/tracing/context.go +++ b/internal/tracing/context.go @@ -9,10 +9,11 @@ import ( type contextKey string const ( - traceIDKey contextKey = "goclaw_trace_id" - parentSpanKey contextKey = "goclaw_parent_span_id" - collectorKey contextKey = "goclaw_trace_collector" - announceParentKey contextKey = "goclaw_announce_parent_span_id" + traceIDKey contextKey = "goclaw_trace_id" + parentSpanKey contextKey = "goclaw_parent_span_id" + collectorKey contextKey = "goclaw_trace_collector" + announceParentKey contextKey = "goclaw_announce_parent_span_id" + delegateParentTraceKey contextKey = "goclaw_delegate_parent_trace_id" ) // WithTraceID returns a context with the given trace ID. @@ -68,3 +69,18 @@ func AnnounceParentSpanIDFromContext(ctx context.Context) uuid.UUID { } return uuid.Nil } + +// WithDelegateParentTraceID sets the parent trace ID for delegation linking. +// Unlike ParentTraceID on RunRequest (which reuses the parent trace for announces), +// this creates a NEW trace that links back to the parent via parent_trace_id. +func WithDelegateParentTraceID(ctx context.Context, id uuid.UUID) context.Context { + return context.WithValue(ctx, delegateParentTraceKey, id) +} + +// DelegateParentTraceIDFromContext extracts the delegation parent trace ID. +func DelegateParentTraceIDFromContext(ctx context.Context) uuid.UUID { + if v, ok := ctx.Value(delegateParentTraceKey).(uuid.UUID); ok { + return v + } + return uuid.Nil +} diff --git a/migrations/000002_agent_links.down.sql b/migrations/000002_agent_links.down.sql new file mode 100644 index 00000000..b9123b80 --- /dev/null +++ b/migrations/000002_agent_links.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS idx_traces_parent; +ALTER TABLE traces DROP COLUMN IF EXISTS parent_trace_id; +DROP TABLE IF EXISTS agent_links; +DROP INDEX IF EXISTS idx_agents_embedding; +DROP INDEX IF EXISTS idx_agents_tsv; +ALTER TABLE agents DROP COLUMN IF EXISTS embedding; +ALTER TABLE agents DROP COLUMN IF EXISTS tsv; +ALTER TABLE agents DROP COLUMN IF EXISTS frontmatter; diff --git a/migrations/000002_agent_links.up.sql b/migrations/000002_agent_links.up.sql new file mode 100644 index 00000000..a422bd8d --- /dev/null +++ b/migrations/000002_agent_links.up.sql @@ -0,0 +1,48 @@ +-- ============================================================ +-- Agent frontmatter (short expertise/capability summary for delegation + UI display). +-- NOTE: This is NOT the same as other_config.description which is the +-- LLM summoning prompt used to initialize the agent's identity. +-- ============================================================ + +ALTER TABLE agents ADD COLUMN IF NOT EXISTS frontmatter TEXT; + +-- Search support: FTS (tsvector) + semantic (pgvector) for agent discovery. +-- Pattern follows skills table: hybrid BM25 + cosine similarity. +-- tsv is auto-generated from display_name + frontmatter. +-- embedding is populated on create/update when an embedding provider is available. +ALTER TABLE agents ADD COLUMN IF NOT EXISTS tsv tsvector + GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(display_name, '') || ' ' || COALESCE(frontmatter, ''))) STORED; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS embedding vector(1536); +CREATE INDEX IF NOT EXISTS idx_agents_tsv ON agents USING GIN(tsv); +CREATE INDEX IF NOT EXISTS idx_agents_embedding ON agents USING hnsw(embedding vector_cosine_ops); + +-- ============================================================ +-- Agent Links (inter-agent delegation permissions) +-- ============================================================ + +CREATE TABLE agent_links ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + source_agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + target_agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + direction VARCHAR(20) NOT NULL DEFAULT 'outbound', + description TEXT, + max_concurrent INT NOT NULL DEFAULT 3, + settings JSONB NOT NULL DEFAULT '{}', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(source_agent_id, target_agent_id), + CHECK (source_agent_id != target_agent_id) +); + +CREATE INDEX idx_agent_links_source ON agent_links(source_agent_id) WHERE status = 'active'; +CREATE INDEX idx_agent_links_target ON agent_links(target_agent_id) WHERE status = 'active'; + +-- ============================================================ +-- Linked traces for delegation: parent_trace_id on traces table +-- allows navigating between caller and delegate traces. +-- ============================================================ + +ALTER TABLE traces ADD COLUMN IF NOT EXISTS parent_trace_id UUID; +CREATE INDEX IF NOT EXISTS idx_traces_parent ON traces(parent_trace_id) WHERE parent_trace_id IS NOT NULL; diff --git a/migrations/000003_agent_teams.down.sql b/migrations/000003_agent_teams.down.sql new file mode 100644 index 00000000..fbcfe297 --- /dev/null +++ b/migrations/000003_agent_teams.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS team_messages; +DROP TABLE IF EXISTS team_tasks; +DROP TABLE IF EXISTS agent_team_members; +DROP TABLE IF EXISTS agent_teams; diff --git a/migrations/000003_agent_teams.up.sql b/migrations/000003_agent_teams.up.sql new file mode 100644 index 00000000..20eb2555 --- /dev/null +++ b/migrations/000003_agent_teams.up.sql @@ -0,0 +1,68 @@ +-- ============================================================ +-- Agent Teams (collaborative multi-agent coordination) +-- ============================================================ + +CREATE TABLE agent_teams ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + name VARCHAR(255) NOT NULL, + lead_agent_id UUID NOT NULL REFERENCES agents(id), + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + settings JSONB NOT NULL DEFAULT '{}', + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE agent_team_members ( + team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (team_id, agent_id) +); + +-- ============================================================ +-- Team Tasks (shared task list with self-claim + dependencies) +-- ============================================================ + +CREATE TABLE team_tasks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + subject VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + owner_agent_id UUID REFERENCES agents(id), + blocked_by UUID[], + priority INT NOT NULL DEFAULT 0, + result TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_team_tasks_team ON team_tasks(team_id); +CREATE INDEX idx_team_tasks_status ON team_tasks(team_id, status); + +-- ============================================================ +-- Team Messages (peer-to-peer mailbox) +-- ============================================================ + +CREATE TABLE team_messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + from_agent_id UUID NOT NULL REFERENCES agents(id), + to_agent_id UUID REFERENCES agents(id), + content TEXT NOT NULL, + message_type VARCHAR(30) NOT NULL DEFAULT 'chat', + read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_team_messages_to ON team_messages(team_id, to_agent_id, read); + +-- ============================================================ +-- Link agent_links to teams: team-created links have team_id set. +-- ON DELETE SET NULL → when team is deleted, links become manual. +-- ============================================================ + +ALTER TABLE agent_links ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES agent_teams(id) ON DELETE SET NULL; diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 8ea458bd..58d30357 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -89,6 +89,23 @@ const ( MethodChannelInstancesDelete = "channels.instances.delete" ) +// Agent links (inter-agent delegation, managed mode) +const ( + MethodAgentsLinksList = "agents.links.list" + MethodAgentsLinksCreate = "agents.links.create" + MethodAgentsLinksUpdate = "agents.links.update" + MethodAgentsLinksDelete = "agents.links.delete" +) + +// Agent teams (managed mode) +const ( + MethodTeamsList = "teams.list" + MethodTeamsCreate = "teams.create" + MethodTeamsGet = "teams.get" + MethodTeamsDelete = "teams.delete" + MethodTeamsTaskList = "teams.tasks.list" +) + // Phase 3+ - NICE TO HAVE methods const ( MethodLogsTail = "logs.tail" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 5512e892..ac4e8b42 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -117,6 +117,19 @@ export const Methods = { SEND: "send", + // Agent links (delegation) + AGENTS_LINKS_LIST: "agents.links.list", + AGENTS_LINKS_CREATE: "agents.links.create", + AGENTS_LINKS_UPDATE: "agents.links.update", + AGENTS_LINKS_DELETE: "agents.links.delete", + + // Agent teams (managed mode) + TEAMS_LIST: "teams.list", + TEAMS_CREATE: "teams.create", + TEAMS_GET: "teams.get", + TEAMS_DELETE: "teams.delete", + TEAMS_TASK_LIST: "teams.tasks.list", + // Phase 3+ - NICE TO HAVE LOGS_TAIL: "logs.tail", HEARTBEAT: "heartbeat", diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index b1b2eec1..8f384d78 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -179,7 +179,7 @@ function FileBlock({ name, mime, content }: { name: string; mime: string; conten - ); - })} - - - {/* Editor */} -
- {selectedFile ? ( - <> -
-
- {selectedFile} - {FILE_DESCRIPTIONS[selectedFile] && ( - - - {FILE_DESCRIPTIONS[selectedFile]} - - )} -
- {canEdit && ( - - )} -
- {loading && !content ? ( -
- Loading... -
- ) : ( -