feat: Introduce agent teams, agent linking, and advanced agent orchestration features.

This commit is contained in:
viettranx committed 2026-02-25 23:24:52 +07:00
1 parent 67b19e4746
commit dfd91556f8
113 files changed
+6785 -672

No files matched your search

+186 -3
View File
@@ -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<br/>Customer Support<br/>Claude Haiku]
subgraph Delegation["Inter-Agent Delegation"]
A -->|"delegate(sync)"| B[Agent B<br/>Research Bot<br/>GPT-4]
A -->|"delegate(async)"| C[Agent C<br/>Content Writer<br/>Claude Sonnet]
B -->|result| A
C -->|announce via Message Bus| A
end
subgraph Permission["Permission Layer"]
LINKS[(agent_links<br/>direction · max_concurrent<br/>settings JSONB)]
LINKS -->|outbound| B
LINKS -->|bidirectional| C
end
subgraph Search["Agent Discovery"]
FTS[FTS<br/>tsvector on agents]
VEC[Semantic<br/>pgvector embeddings]
FTS & VEC -->|hybrid 0.3 + 0.7| RESULTS[delegate_search tool]
end
A -.->|"≤15 targets"| AGENTSMD["AGENTS.md<br/>(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.
+12 -2
View File
@@ -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.
+1
View File
@@ -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,
+9
View File
@@ -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.
+163 -1
View File
@@ -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
}
+70
View File
@@ -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")
}
+2 -2
View File
@@ -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)
}
}
+2 -2
View File
@@ -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}`),
}
+57 -18
View File
@@ -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",
+41 -9
View File
@@ -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
+31 -18
View File
@@ -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.
}
+184
View File
@@ -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=<brief title> → returns task_id\n")
sb.WriteString("2. `delegate` agent=<member>, task=<instructions>, team_task_id=<the task_id from step 1>\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=<id>, result=<summary> → 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()
}
+22 -3
View File
@@ -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 <system_context> so the LLM doesn't try to read/write them as files.
if base == bootstrap.DelegationFile || base == bootstrap.TeamFile {
lines = append(lines,
fmt.Sprintf("<system_context name=%q>", base),
f.Content,
"</system_context>",
"",
)
continue
}
lines = append(lines,
fmt.Sprintf("## %s", f.Path),
fmt.Sprintf("<context_file name=%q>", 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
}
}
+5 -2
View File
@@ -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.
+3 -3
View File
@@ -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"
}
+11 -1
View File
@@ -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
}
+11 -10
View File
@@ -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)
}
}
+6 -3
View File
@@ -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:
+272 -6
View File
@@ -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 <id> — 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 <id>" or "/task_detail@botname <id>"
parts := strings.SplitN(text, " ", 2)
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
send("Usage: /task_detail <task_id>")
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)"},
+8 -8
View File
@@ -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
}
+6
View File
@@ -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}
+357
View File
@@ -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, &params); 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, &params); 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, &params); 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, &params); 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)
}
+4 -4
View File
@@ -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) {
@@ -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},
})
}
+320
View File
@@ -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, &params); 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, &params); 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, &params); 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, &params); 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)
}
}
}
}
+15 -15
View File
@@ -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.
+1 -1
View File
@@ -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},
})
}
+1 -1
View File
@@ -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},
})
}
+56 -21
View File
@@ -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 <file name="SOUL.md">content</file> from LLM output.
var fileTagRe = regexp.MustCompile(`(?s)<file\s+name="([^"]+)">\s*(.*?)\s*</file>`)
// frontmatterTagRe parses <frontmatter>short expertise summary</frontmatter> from LLM output.
var frontmatterTagRe = regexp.MustCompile(`(?s)<frontmatter>\s*(.*?)\s*</frontmatter>`)
// 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.
<frontmatter>
(short expertise summary here)
</frontmatter>
Then generate each file inside XML tags:
<file name="SOUL.md">
(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
}
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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
}
+30 -19
View File
@@ -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) {
+31 -4
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+74
View File
@@ -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)
}
+15
View File
@@ -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"`
+9 -8
View File
@@ -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,
}
+286
View File
@@ -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()
}
+112 -8
View File
@@ -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
+2
View File
@@ -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
}
+381
View File
@@ -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()
}
+11 -9
View File
@@ -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)
+2
View File
@@ -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
}
+130
View File
@@ -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
}
+30
View File
@@ -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"`
+22 -22
View File
@@ -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 != ""
}
+547
View File
@@ -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)
}
+180
View File
@@ -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=\"<agent_key>\", 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
}
+161
View File
@@ -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))
}
+3 -2
View File
@@ -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")
+15 -7
View File
@@ -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()
+7 -7
View File
@@ -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)
}
+13 -13
View File
@@ -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)
+190
View File
@@ -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,
},
})
}
+203
View File
@@ -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))
}
+60
View File
@@ -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
}
+20 -4
View File
@@ -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
}
+8
View File
@@ -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;
+48
View File
@@ -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;
+4
View File
@@ -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;
+68
View File
@@ -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;
+17
View File
@@ -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"
+13
View File
@@ -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",
+1 -1
View File
@@ -179,7 +179,7 @@ function FileBlock({ name, mime, content }: { name: string; mime: string; conten
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
+2
View File
@@ -11,6 +11,7 @@ import {
Terminal,
Settings,
ShieldCheck,
Users,
Link,
Wrench,
Plug,
@@ -56,6 +57,7 @@ export function Sidebar({ collapsed }: SidebarProps) {
<SidebarGroup label="Management" collapsed={collapsed}>
<SidebarItem to={ROUTES.AGENTS} icon={Bot} label="Agents" collapsed={collapsed} />
<SidebarItem to={ROUTES.TEAMS} icon={Users} label="Agent Teams" collapsed={collapsed} />
<SidebarItem to={ROUTES.SESSIONS} icon={History} label="Sessions" collapsed={collapsed} />
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label="Channels" collapsed={collapsed} />
<SidebarItem to={ROUTES.SKILLS} icon={Zap} label="Skills" collapsed={collapsed} />
+3 -3
View File
@@ -17,7 +17,7 @@ export function Topbar() {
<div className="flex items-center gap-2">
<button
onClick={toggleSidebar}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
@@ -35,7 +35,7 @@ export function Topbar() {
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="Toggle theme"
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
@@ -43,7 +43,7 @@ export function Topbar() {
<button
onClick={logout}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="Logout"
>
<LogOut className="h-4 w-4" />
@@ -22,7 +22,7 @@ function CodeBlock({
<button
type="button"
onClick={() => copy(text)}
className="opacity-0 transition-opacity group-hover:opacity-100"
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title="Copy code"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
+7 -5
View File
@@ -26,10 +26,11 @@ export function Combobox({
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
// Sync search text when value changes externally
// Sync search text when value changes externally — show label if available
React.useEffect(() => {
setSearch(value);
}, [value]);
const match = options.find((o) => o.value === value);
setSearch(match?.label || value);
}, [value, options]);
// Close on outside click
React.useEffect(() => {
@@ -55,7 +56,8 @@ export function Combobox({
const handleSelect = (val: string) => {
onChange(val);
setSearch(val);
const match = options.find((o) => o.value === val);
setSearch(match?.label || val);
setOpen(false);
};
@@ -92,7 +94,7 @@ export function Combobox({
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(o.value)}
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
>
<span className="truncate">{o.label || o.value}</span>
{o.value === value && (
+2
View File
@@ -19,6 +19,8 @@ export const ROUTES = {
NODES: "/nodes",
LOGS: "/logs",
PROVIDERS: "/providers",
TEAMS: "/teams",
TEAM_DETAIL: "/teams/:id",
CUSTOM_TOOLS: "/custom-tools",
MCP: "/mcp",
TTS: "/tts",
+7
View File
@@ -62,6 +62,13 @@ export function AgentCard({ agent, onClick, onResummon }: AgentCardProps) {
</div>
)}
{/* Expertise summary */}
{agent.frontmatter && (
<div className="line-clamp-3 text-xs text-muted-foreground/70">
{agent.frontmatter}
</div>
)}
{/* Bottom badges */}
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
@@ -8,6 +8,7 @@ import { AgentGeneralTab } from "./agent-general-tab";
import { AgentConfigTab } from "./agent-config-tab";
import { AgentFilesTab } from "./agent-files-tab";
import { AgentSharesTab } from "./agent-shares-tab";
import { AgentLinksTab } from "./agent-links-tab";
import { SummoningModal } from "../summoning-modal";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
@@ -102,6 +103,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsTrigger value="config">Config</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="shares">Shares</TabsTrigger>
<TabsTrigger value="links">Links</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
@@ -125,6 +127,10 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsContent value="shares" className="mt-4">
<AgentSharesTab agentId={agentId} />
</TabsContent>
<TabsContent value="links" className="mt-4">
<AgentLinksTab agentId={agentId} />
</TabsContent>
</Tabs>
</div>
@@ -1,17 +1,14 @@
import { useState, useEffect } from "react";
import { FileText, Save, Info, Lock, Sparkles } from "lucide-react";
import { Lock, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/use-auth-store";
import type { AgentData, BootstrapFile } from "@/types/agent";
import {
FileSidebar,
FileEditor,
RegenerateDialog,
OpenAgentEmptyState,
} from "./file-sections";
interface AgentFilesTabProps {
agent: AgentData;
@@ -21,18 +18,13 @@ interface AgentFilesTabProps {
onRegenerate?: (prompt: string) => Promise<void>;
}
const FILE_DESCRIPTIONS: Record<string, string> = {
"AGENTS.md": "Operating instructions and agent behavior rules",
"SOUL.md": "Persona, tone, and behavioral boundaries",
"TOOLS.md": "Notes about available tools and their usage",
"IDENTITY.md": "Agent name, emoji, avatar, and description",
"USER.md": "User profile (per-user customizable)",
"HEARTBEAT.md": "Periodic tasks configuration",
"BOOTSTRAP.md": "First-run ritual (auto-deleted after completion)",
"MEMORY.json": "Long-term memory data",
};
export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate }: AgentFilesTabProps) {
export function AgentFilesTab({
agent,
files,
onGetFile,
onSetFile,
onRegenerate,
}: AgentFilesTabProps) {
const userId = useAuthStore((s) => s.userId);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState("");
@@ -40,23 +32,23 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [regenerateOpen, setRegenerateOpen] = useState(false);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [regenerating, setRegenerating] = useState(false);
const isOpen = agent.agent_type === "open";
const isPredefined = agent.agent_type === "predefined";
const isOwner = agent.owner_id === userId;
const canEdit = !isPredefined || isOwner;
// Hide MEMORY.json — memory is managed by the memory system, not context files
// Hide BOOTSTRAP.md for predefined agents — it's a per-user onboarding file, not agent-level
const displayFiles = files.filter((f) =>
f.name !== "MEMORY.json" && !(isPredefined && f.name === "BOOTSTRAP.md"),
// Hide MEMORY.json and BOOTSTRAP.md for predefined agents
const displayFiles = files.filter(
(f) =>
f.name !== "MEMORY.json" &&
!(isPredefined && f.name === "BOOTSTRAP.md"),
);
const allMissing = displayFiles.length > 0 && displayFiles.every((f) => f.missing);
const allMissing =
displayFiles.length > 0 && displayFiles.every((f) => f.missing);
// USER.md is per-user at runtime — not editable from the agent admin UI for predefined agents
const isUserScoped = (fileName: string) => isPredefined && fileName === "USER.md";
const isUserScoped = (fileName: string) =>
isPredefined && fileName === "USER.md";
useEffect(() => {
if (!selectedFile) return;
@@ -80,45 +72,13 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
}
};
// For open agents with no agent-level files, show explanation
if (isOpen && allMissing) {
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-info/30 bg-sky-500/5 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-sky-600 dark:text-sky-400" />
<div className="space-y-2 text-sm">
<p className="font-medium">Open Agent - Per-User Context Files</p>
<p className="text-muted-foreground">
This is an <strong>open</strong> agent. Context files (AGENTS.md, SOUL.md, TOOLS.md, etc.)
are personalized for each user. They are automatically created from templates when a user
first chats with this agent.
</p>
<p className="text-muted-foreground">
Agent-level files shown here are empty because open agents store all context per-user
in the <code className="rounded bg-muted px-1 py-0.5 text-xs">user_context_files</code> table.
</p>
</div>
</div>
const handleContentChange = (val: string) => {
setContent(val);
setDirty(true);
};
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-medium">Context Files</h4>
<div className="space-y-2">
{displayFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 rounded-md bg-muted/50 px-3 py-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground">
{FILE_DESCRIPTIONS[file.name] || "Context file"}
</div>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">per-user</Badge>
</div>
))}
</div>
</div>
</div>
);
if (isOpen && allMissing) {
return <OpenAgentEmptyState files={displayFiles} />;
}
return (
@@ -150,135 +110,31 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
)}
<div className="flex h-[600px] gap-4">
{/* File list */}
<div className="w-52 space-y-1 overflow-y-auto border-r pr-4">
{displayFiles.map((file) => {
const userScoped = isUserScoped(file.name);
return (
<button
key={file.name}
type="button"
onClick={() => !userScoped && setSelectedFile(file.name)}
disabled={userScoped}
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
userScoped
? "cursor-not-allowed opacity-60"
: selectedFile === file.name
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{file.name}</span>
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">per-user</Badge>
) : file.missing ? (
<Badge variant="outline" className="shrink-0 text-[10px]">empty</Badge>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{file.size > 1024 ? `${(file.size / 1024).toFixed(1)}K` : `${file.size}B`}
</span>
)}
</button>
);
})}
</div>
{/* Editor */}
<div className="flex flex-1 flex-col">
{selectedFile ? (
<>
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-sm font-medium">{selectedFile}</span>
{FILE_DESCRIPTIONS[selectedFile] && (
<span className="ml-2 text-xs text-muted-foreground">
- {FILE_DESCRIPTIONS[selectedFile]}
</span>
)}
</div>
{canEdit && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
>
{!saving && <Save className="h-3.5 w-3.5" />}
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
) : (
<Textarea
value={content}
onChange={(e) => {
if (!canEdit) return;
setContent(e.target.value);
setDirty(true);
}}
readOnly={!canEdit}
className={`flex-1 resize-none font-mono text-sm ${!canEdit ? "opacity-70" : ""}`}
placeholder="File content..."
/>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to {canEdit ? "edit" : "view"}
</div>
)}
</div>
<FileSidebar
files={displayFiles}
selectedFile={selectedFile}
onSelect={setSelectedFile}
isUserScoped={isUserScoped}
/>
<FileEditor
fileName={selectedFile}
content={content}
onChange={handleContentChange}
loading={loading}
dirty={dirty}
saving={saving}
canEdit={canEdit}
onSave={handleSave}
/>
</div>
{/* Edit with AI dialog */}
<Dialog open={regenerateOpen} onOpenChange={setRegenerateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Edit with AI
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
Describe what you want to change. AI will read the current files and update them accordingly.
</p>
<Textarea
value={regeneratePrompt}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="e.g. Make the agent more formal, add Vietnamese language support, change the name to Luna..."
className="min-h-[100px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenerateOpen(false)} disabled={regenerating}>
Cancel
</Button>
<Button
onClick={async () => {
if (!onRegenerate || !regeneratePrompt.trim()) return;
setRegenerating(true);
try {
await onRegenerate(regeneratePrompt.trim());
setRegenerateOpen(false);
setRegeneratePrompt("");
} finally {
setRegenerating(false);
}
}}
disabled={!regeneratePrompt.trim() || regenerating}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{regenerating ? "Sending..." : "Regenerate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{onRegenerate && (
<RegenerateDialog
open={regenerateOpen}
onOpenChange={setRegenerateOpen}
onRegenerate={onRegenerate}
/>
)}
</div>
);
}
@@ -1,22 +1,9 @@
import { useState, useMemo, useEffect } from "react";
import { Save, Copy, Check, AlertCircle } from "lucide-react";
import { useState, useCallback } from "react";
import { Save, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import type { AgentData } from "@/types/agent";
import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections";
interface AgentGeneralTabProps {
agent: AgentData;
@@ -24,43 +11,31 @@ interface AgentGeneralTabProps {
}
export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
// Identity
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [provider, setProvider] = useState(agent.provider);
const [model, setModel] = useState(agent.model);
const selectedProviderId = useMemo(
() => enabledProviders.find((p) => p.name === provider)?.id,
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
// Track whether provider/model changed from saved values
const llmChanged = provider !== agent.provider || model !== agent.model;
// Reset verification when provider or model changes
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
const [frontmatter, setFrontmatter] = useState(agent.frontmatter ?? "");
const [status, setStatus] = useState(agent.status);
const [isDefault, setIsDefault] = useState(agent.is_default);
// LLM
const [provider, setProvider] = useState(agent.provider);
const [model, setModel] = useState(agent.model);
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
const [llmSaveBlocked, setLlmSaveBlocked] = useState(false);
// Workspace
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
// Save state
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handleSaveBlockedChange = useCallback((blocked: boolean) => {
setLlmSaveBlocked(blocked);
}, []);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
@@ -68,6 +43,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
try {
await onUpdate({
display_name: displayName,
frontmatter: frontmatter || null,
provider,
model,
context_window: contextWindow,
@@ -85,185 +61,43 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
}
};
const copyAgentKey = async () => {
await navigator.clipboard.writeText(agent.agent_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="max-w-2xl space-y-6">
{/* Identity */}
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Identity</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label>Agent Key</Label>
<div className="flex items-center gap-2">
<Input value={agent.agent_key} disabled className="font-mono text-sm" />
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyAgentKey}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">Unique identifier, cannot be changed.</p>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. My Assistant"
/>
<p className="text-xs text-muted-foreground">
Friendly name shown in the UI. Leave empty to use the agent key.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
{status === "summon_failed" && (
<SelectItem value="summon_failed" disabled>Summon Failed</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
<Label>Default Agent</Label>
</div>
</div>
</div>
</div>
</section>
<IdentitySection
agentKey={agent.agent_key}
displayName={displayName}
onDisplayNameChange={setDisplayName}
frontmatter={frontmatter}
onFrontmatterChange={setFrontmatter}
status={status}
onStatusChange={setStatus}
isDefault={isDefault}
onIsDefaultChange={setIsDefault}
/>
<Separator />
{/* LLM Configuration */}
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">LLM Configuration</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={(v) => { setProvider(v); setModel(""); }}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={provider}
onChange={(e) => setProvider(e.target.value)}
placeholder="openrouter"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
{llmChanged && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
)}
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contextWindow">Context Window</Label>
<Input
id="contextWindow"
type="number"
value={contextWindow || ""}
onChange={(e) => setContextWindow(Number(e.target.value) || 0)}
placeholder="200000"
/>
<p className="text-xs text-muted-foreground">Token limit for the model context.</p>
</div>
<div className="space-y-2">
<Label htmlFor="maxToolIterations">Max Tool Iterations</Label>
<Input
id="maxToolIterations"
type="number"
value={maxToolIterations || ""}
onChange={(e) => setMaxToolIterations(Number(e.target.value) || 0)}
placeholder="25"
/>
<p className="text-xs text-muted-foreground">Max tool calls per request.</p>
</div>
</div>
</div>
</section>
<LlmConfigSection
provider={provider}
onProviderChange={setProvider}
model={model}
onModelChange={setModel}
contextWindow={contextWindow}
onContextWindowChange={setContextWindow}
maxToolIterations={maxToolIterations}
onMaxToolIterationsChange={setMaxToolIterations}
savedProvider={agent.provider}
savedModel={agent.model}
onSaveBlockedChange={handleSaveBlockedChange}
/>
<Separator />
{/* Workspace */}
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label>Workspace Path</Label>
<p className="rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm text-muted-foreground">
{agent.workspace || "No workspace configured"}
</p>
<p className="text-xs text-muted-foreground">
Automatically assigned when the agent is created. Per-user subdirectories are created at runtime.
</p>
</div>
<div className="flex items-center gap-2">
<Switch checked={restrictToWorkspace} onCheckedChange={setRestrictToWorkspace} />
<div>
<Label>Restrict to Workspace</Label>
<p className="text-xs text-muted-foreground">
Confine file access strictly within the workspace path.
</p>
</div>
</div>
</div>
</section>
<WorkspaceSection
workspace={agent.workspace}
restrictToWorkspace={restrictToWorkspace}
onRestrictChange={setRestrictToWorkspace}
/>
{/* Save */}
{saveError && (
@@ -278,7 +112,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
<Check className="h-3.5 w-3.5" /> Saved
</span>
)}
<Button onClick={handleSave} disabled={saving || (llmChanged && !verifyResult?.valid)}>
<Button onClick={handleSave} disabled={saving || llmSaveBlocked}>
{!saving && <Save className="h-4 w-4" />}
{saving ? "Saving..." : "Save Changes"}
</Button>
@@ -0,0 +1,85 @@
import { useState, useEffect, useMemo } from "react";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useAgentLinks } from "../hooks/use-agent-links";
import { useAgents } from "../hooks/use-agents";
import type { AgentLinkData } from "@/types/agent";
import { LinkCreateForm, LinkList, LinkEditDialog, linkTargetName } from "./link-sections";
interface AgentLinksTabProps {
agentId: string;
}
export function AgentLinksTab({ agentId }: AgentLinksTabProps) {
const { links, loading, load, createLink, updateLink, deleteLink } =
useAgentLinks(agentId);
const { agents } = useAgents();
const [editLink, setEditLink] = useState<AgentLinkData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
name: string;
} | null>(null);
useEffect(() => {
load();
}, [load]);
// Only predefined agents can be delegation targets (open agents have no agent-level context)
const agentOptions = useMemo(
() =>
agents
.filter((a) => a.id !== agentId && a.agent_type === "predefined")
.map((a) => ({
value: a.id,
label: a.display_name || a.agent_key,
})),
[agents, agentId],
);
const handleStatusToggle = async (link: AgentLinkData) => {
const newStatus = link.status === "active" ? "disabled" : "active";
await updateLink(link.id, { status: newStatus });
};
return (
<div className="max-w-4xl space-y-6">
<LinkCreateForm agentOptions={agentOptions} onSubmit={createLink} />
<LinkList
links={links}
loading={loading}
agentId={agentId}
onStatusToggle={handleStatusToggle}
onEdit={setEditLink}
onDelete={(link) =>
setDeleteTarget({ id: link.id, name: linkTargetName(link, agentId) })
}
/>
<LinkEditDialog
link={editLink}
onClose={() => setEditLink(null)}
onSave={updateLink}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
title="Delete Link"
description={`Remove the delegation link to "${deleteTarget?.name}"? Agents will no longer be able to delegate to each other through this link.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
if (deleteTarget) {
try {
await deleteLink(deleteTarget.id);
} catch {
// ignore
}
setDeleteTarget(null);
}
}}
/>
</div>
);
}
@@ -0,0 +1,71 @@
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FILE_DESCRIPTIONS } from "./file-utils";
interface FileEditorProps {
fileName: string | null;
content: string;
onChange: (content: string) => void;
loading: boolean;
dirty: boolean;
saving: boolean;
canEdit: boolean;
onSave: () => void;
}
export function FileEditor({
fileName,
content,
onChange,
loading,
dirty,
saving,
canEdit,
onSave,
}: FileEditorProps) {
if (!fileName) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to {canEdit ? "edit" : "view"}
</div>
);
}
return (
<div className="flex flex-1 flex-col">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-sm font-medium">{fileName}</span>
{FILE_DESCRIPTIONS[fileName] && (
<span className="ml-2 text-xs text-muted-foreground">
- {FILE_DESCRIPTIONS[fileName]}
</span>
)}
</div>
{canEdit && (
<Button size="sm" onClick={onSave} disabled={!dirty || saving}>
{!saving && <Save className="h-3.5 w-3.5" />}
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
) : (
<Textarea
value={content}
onChange={(e) => {
if (!canEdit) return;
onChange(e.target.value);
}}
readOnly={!canEdit}
className={`flex-1 resize-none font-mono text-sm ${!canEdit ? "opacity-70" : ""}`}
placeholder="File content..."
/>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { FileText } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { BootstrapFile } from "@/types/agent";
interface FileSidebarProps {
files: BootstrapFile[];
selectedFile: string | null;
onSelect: (name: string) => void;
isUserScoped: (name: string) => boolean;
}
export function FileSidebar({
files,
selectedFile,
onSelect,
isUserScoped,
}: FileSidebarProps) {
return (
<div className="w-52 space-y-1 overflow-y-auto border-r pr-4">
{files.map((file) => {
const userScoped = isUserScoped(file.name);
return (
<button
key={file.name}
type="button"
onClick={() => !userScoped && onSelect(file.name)}
disabled={userScoped}
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
userScoped
? "cursor-not-allowed opacity-60"
: selectedFile === file.name
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">
{file.name}
</span>
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
per-user
</Badge>
) : file.missing ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
empty
</Badge>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{file.size > 1024
? `${(file.size / 1024).toFixed(1)}K`
: `${file.size}B`}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,10 @@
export const FILE_DESCRIPTIONS: Record<string, string> = {
"AGENTS.md": "Operating instructions and agent behavior rules",
"SOUL.md": "Persona, tone, and behavioral boundaries",
"TOOLS.md": "Notes about available tools and their usage",
"IDENTITY.md": "Agent name, emoji, avatar, and description",
"USER.md": "User profile (per-user customizable)",
"HEARTBEAT.md": "Periodic tasks configuration",
"BOOTSTRAP.md": "First-run ritual (auto-deleted after completion)",
"MEMORY.json": "Long-term memory data",
};
@@ -0,0 +1,5 @@
export { FileSidebar } from "./file-sidebar";
export { FileEditor } from "./file-editor";
export { RegenerateDialog } from "./regenerate-dialog";
export { OpenAgentEmptyState } from "./open-agent-empty-state";
export { FILE_DESCRIPTIONS } from "./file-utils";
@@ -0,0 +1,58 @@
import { FileText, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { BootstrapFile } from "@/types/agent";
import { FILE_DESCRIPTIONS } from "./file-utils";
interface OpenAgentEmptyStateProps {
files: BootstrapFile[];
}
export function OpenAgentEmptyState({ files }: OpenAgentEmptyStateProps) {
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-info/30 bg-sky-500/5 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-sky-600 dark:text-sky-400" />
<div className="space-y-2 text-sm">
<p className="font-medium">Open Agent - Per-User Context Files</p>
<p className="text-muted-foreground">
This is an <strong>open</strong> agent. Context files (AGENTS.md,
SOUL.md, TOOLS.md, etc.) are personalized for each user. They are
automatically created from templates when a user first chats with
this agent.
</p>
<p className="text-muted-foreground">
Agent-level files shown here are empty because open agents store all
context per-user in the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
user_context_files
</code>{" "}
table.
</p>
</div>
</div>
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-medium">Context Files</h4>
<div className="space-y-2">
{files.map((file) => (
<div
key={file.name}
className="flex items-center gap-3 rounded-md bg-muted/50 px-3 py-2"
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground">
{FILE_DESCRIPTIONS[file.name] || "Context file"}
</div>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">
per-user
</Badge>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface RegenerateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onRegenerate: (prompt: string) => Promise<void>;
}
export function RegenerateDialog({
open,
onOpenChange,
onRegenerate,
}: RegenerateDialogProps) {
const [prompt, setPrompt] = useState("");
const [regenerating, setRegenerating] = useState(false);
const handleSubmit = async () => {
if (!prompt.trim()) return;
setRegenerating(true);
try {
await onRegenerate(prompt.trim());
onOpenChange(false);
setPrompt("");
} finally {
setRegenerating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Edit with AI
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
Describe what you want to change. AI will read the current files and
update them accordingly.
</p>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Make the agent more formal, add Vietnamese language support, change the name to Luna..."
className="min-h-[100px]"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={regenerating}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!prompt.trim() || regenerating}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{regenerating ? "Sending..." : "Regenerate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,123 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface IdentitySectionProps {
agentKey: string;
displayName: string;
onDisplayNameChange: (v: string) => void;
frontmatter: string;
onFrontmatterChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
isDefault: boolean;
onIsDefaultChange: (v: boolean) => void;
}
export function IdentitySection({
agentKey,
displayName,
onDisplayNameChange,
frontmatter,
onFrontmatterChange,
status,
onStatusChange,
isDefault,
onIsDefaultChange,
}: IdentitySectionProps) {
const [copied, setCopied] = useState(false);
const copyAgentKey = async () => {
await navigator.clipboard.writeText(agentKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Identity</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label>Agent Key</Label>
<div className="flex items-center gap-2">
<Input value={agentKey} disabled className="font-mono text-sm" />
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyAgentKey}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Unique identifier, cannot be changed.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => onDisplayNameChange(e.target.value)}
placeholder="e.g. My Assistant"
/>
<p className="text-xs text-muted-foreground">
Friendly name shown in the UI. Leave empty to use the agent key.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="frontmatter">Expertise Summary</Label>
<Input
id="frontmatter"
value={frontmatter}
onChange={(e) => onFrontmatterChange(e.target.value)}
placeholder="e.g. Chiêm tinh, bói toán, tử vi, thần số học"
/>
<p className="text-xs text-muted-foreground">
Short description of this agent's expertise. Used for delegation discovery.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={onStatusChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
{status === "summon_failed" && (
<SelectItem value="summon_failed" disabled>
Summon Failed
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch checked={isDefault} onCheckedChange={onIsDefaultChange} />
<Label>Default Agent</Label>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,3 @@
export { IdentitySection } from "./identity-section";
export { LlmConfigSection } from "./llm-config-section";
export { WorkspaceSection } from "./workspace-section";
@@ -0,0 +1,163 @@
import { useMemo, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
interface LlmConfigSectionProps {
provider: string;
onProviderChange: (v: string) => void;
model: string;
onModelChange: (v: string) => void;
contextWindow: number;
onContextWindowChange: (v: number) => void;
maxToolIterations: number;
onMaxToolIterationsChange: (v: number) => void;
savedProvider: string;
savedModel: string;
/** Called when verification status changes. True = save should be blocked. */
onSaveBlockedChange?: (blocked: boolean) => void;
}
export function LlmConfigSection({
provider,
onProviderChange,
model,
onModelChange,
contextWindow,
onContextWindowChange,
maxToolIterations,
onMaxToolIterationsChange,
savedProvider,
savedModel,
onSaveBlockedChange,
}: LlmConfigSectionProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProviderId = useMemo(
() => enabledProviders.find((p) => p.name === provider)?.id,
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
const llmChanged = provider !== savedProvider || model !== savedModel;
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
// Report save-blocked status to parent
useEffect(() => {
onSaveBlockedChange?.(llmChanged && !verifyResult?.valid);
}, [llmChanged, verifyResult, onSaveBlockedChange]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">LLM Configuration</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select
value={provider}
onValueChange={(v) => {
onProviderChange(v);
onModelChange("");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={provider}
onChange={(e) => onProviderChange(e.target.value)}
placeholder="openrouter"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={onModelChange}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
{llmChanged && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
)}
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contextWindow">Context Window</Label>
<Input
id="contextWindow"
type="number"
value={contextWindow || ""}
onChange={(e) => onContextWindowChange(Number(e.target.value) || 0)}
placeholder="200000"
/>
<p className="text-xs text-muted-foreground">Token limit for the model context.</p>
</div>
<div className="space-y-2">
<Label htmlFor="maxToolIterations">Max Tool Iterations</Label>
<Input
id="maxToolIterations"
type="number"
value={maxToolIterations || ""}
onChange={(e) => onMaxToolIterationsChange(Number(e.target.value) || 0)}
placeholder="25"
/>
<p className="text-xs text-muted-foreground">Max tool calls per request.</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,44 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface WorkspaceSectionProps {
workspace: string;
restrictToWorkspace: boolean;
onRestrictChange: (v: boolean) => void;
}
export function WorkspaceSection({
workspace,
restrictToWorkspace,
onRestrictChange,
}: WorkspaceSectionProps) {
return (
<section className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label>Workspace Path</Label>
<p className="rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm text-muted-foreground">
{workspace || "No workspace configured"}
</p>
<p className="text-xs text-muted-foreground">
Automatically assigned when the agent is created. Per-user
subdirectories are created at runtime.
</p>
</div>
<div className="flex items-center gap-2">
<Switch
checked={restrictToWorkspace}
onCheckedChange={onRestrictChange}
/>
<div>
<Label>Restrict to Workspace</Label>
<p className="text-xs text-muted-foreground">
Confine file access strictly within the workspace path.
</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,11 @@
export { LinkCreateForm } from "./link-create-form";
export { LinkList } from "./link-list";
export { LinkEditDialog } from "./link-edit-dialog";
export {
DIRECTION_OPTIONS,
directionBadgeVariant,
linkTargetName,
buildSettings,
parseCommaSeparated,
joinArray,
} from "./link-utils";
@@ -0,0 +1,173 @@
import { useState } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentLinkSettings } from "@/types/agent";
import { DIRECTION_OPTIONS, buildSettings } from "./link-utils";
interface LinkCreateFormProps {
agentOptions: ComboboxOption[];
onSubmit: (params: {
targetAgent: string;
direction: string;
description?: string;
maxConcurrent?: number;
settings?: AgentLinkSettings;
}) => Promise<void>;
}
export function LinkCreateForm({ agentOptions, onSubmit }: LinkCreateFormProps) {
const [targetAgent, setTargetAgent] = useState("");
const [direction, setDirection] = useState("outbound");
const [description, setDescription] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("3");
const [showAdvanced, setShowAdvanced] = useState(false);
const [userAllow, setUserAllow] = useState("");
const [userDeny, setUserDeny] = useState("");
const [creating, setCreating] = useState(false);
const isValidTarget = agentOptions.some((o) => o.value === targetAgent);
const handleCreate = async () => {
if (!isValidTarget) return;
setCreating(true);
try {
await onSubmit({
targetAgent,
direction,
description: description || undefined,
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
settings: buildSettings(userAllow, userDeny),
});
setTargetAgent("");
setDirection("outbound");
setDescription("");
setMaxConcurrent("3");
setUserAllow("");
setUserDeny("");
setShowAdvanced(false);
} catch {
// ignore
} finally {
setCreating(false);
}
};
return (
<div className="rounded-lg border p-4">
<h3 className="mb-3 text-sm font-medium">Create Link</h3>
<div className="space-y-3">
<div className="flex items-end gap-3">
<div className="flex-1 space-y-1.5">
<Label>Target Agent</Label>
<Combobox
value={targetAgent}
onChange={setTargetAgent}
options={agentOptions}
placeholder="Select or search agent..."
/>
</div>
<div className="w-44 space-y-1.5">
<Label>Direction</Label>
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex flex-col">
<span>{opt.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
</p>
<div className="flex items-end gap-3">
<div className="flex-1 space-y-1.5">
<Label>Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description of the link..."
/>
</div>
<div className="w-32 space-y-1.5">
<Label>Max Concurrent</Label>
<Input
type="number"
min={1}
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
/>
</div>
</div>
{/* Advanced settings */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{showAdvanced ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Advanced Settings
</button>
{showAdvanced && (
<div className="space-y-3 rounded-md border border-dashed p-3">
<div className="space-y-1.5">
<Label>Allowed Users</Label>
<Input
value={userAllow}
onChange={(e) => setUserAllow(e.target.value)}
placeholder="user1, user2, ..."
/>
<p className="text-xs text-muted-foreground">
If set, only these users can trigger this delegation. Comma-separated.
</p>
</div>
<div className="space-y-1.5">
<Label>Denied Users</Label>
<Input
value={userDeny}
onChange={(e) => setUserDeny(e.target.value)}
placeholder="user3, user4, ..."
/>
<p className="text-xs text-muted-foreground">
These users are blocked from triggering this delegation. Comma-separated.
</p>
</div>
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleCreate}
disabled={!isValidTarget || creating}
className="gap-1.5"
>
<Plus className="h-4 w-4" />
{creating ? "Creating..." : "Create Link"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { AgentLinkData } from "@/types/agent";
import { DIRECTION_OPTIONS, joinArray, buildSettings } from "./link-utils";
interface LinkEditDialogProps {
link: AgentLinkData | null;
onClose: () => void;
onSave: (linkId: string, updates: Record<string, unknown>) => Promise<void>;
}
export function LinkEditDialog({ link, onClose, onSave }: LinkEditDialogProps) {
const [direction, setDirection] = useState("outbound");
const [description, setDescription] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("3");
const [userAllow, setUserAllow] = useState("");
const [userDeny, setUserDeny] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (link) {
setDirection(link.direction);
setDescription(link.description ?? "");
setMaxConcurrent(String(link.max_concurrent));
setUserAllow(joinArray(link.settings?.user_allow));
setUserDeny(joinArray(link.settings?.user_deny));
}
}, [link]);
const handleSave = async () => {
if (!link) return;
setSaving(true);
try {
await onSave(link.id, {
direction,
description,
maxConcurrent: parseInt(maxConcurrent, 10) || 3,
settings: buildSettings(userAllow, userDeny),
});
onClose();
} catch {
// ignore
} finally {
setSaving(false);
}
};
return (
<Dialog open={!!link} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Link</DialogTitle>
<DialogDescription>
Update the delegation link settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Direction</Label>
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{DIRECTION_OPTIONS.find((o) => o.value === direction)?.desc}
</p>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description..."
/>
</div>
<div className="space-y-1.5">
<Label>Max Concurrent</Label>
<Input
type="number"
min={1}
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Allowed Users</Label>
<Input
value={userAllow}
onChange={(e) => setUserAllow(e.target.value)}
placeholder="user1, user2, ..."
/>
<p className="text-xs text-muted-foreground">
If set, only these users can trigger this delegation.
</p>
</div>
<div className="space-y-1.5">
<Label>Denied Users</Label>
<Input
value={userDeny}
onChange={(e) => setUserDeny(e.target.value)}
placeholder="user3, user4, ..."
/>
<p className="text-xs text-muted-foreground">
These users are blocked from triggering this delegation.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,117 @@
import { Link2, Pencil, Trash2, Users } from "lucide-react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import type { AgentLinkData } from "@/types/agent";
import { directionBadgeVariant, linkTargetName } from "./link-utils";
interface LinkListProps {
links: AgentLinkData[];
loading: boolean;
agentId: string;
onStatusToggle: (link: AgentLinkData) => void;
onEdit: (link: AgentLinkData) => void;
onDelete: (link: AgentLinkData) => void;
}
export function LinkList({
links,
loading,
agentId,
onStatusToggle,
onEdit,
onDelete,
}: LinkListProps) {
if (loading && links.length === 0) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading links...
</div>
);
}
if (links.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Link2 className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No agent links yet</p>
<p className="text-xs text-muted-foreground">
Create links to allow agents to delegate tasks to each other.
</p>
</div>
);
}
return (
<div className="rounded-lg border">
<div className="grid grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Target</span>
<span>Direction</span>
<span>Status</span>
<span>Limit</span>
<span />
</div>
{links.map((link) => (
<div
key={link.id}
className="grid grid-cols-[1fr_100px_60px_60px_80px] items-center gap-2 border-b px-4 py-3 last:border-0"
>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">
{linkTargetName(link, agentId)}
</span>
{link.team_id && (
<Link to={`/teams/${link.team_id}`}>
<Badge variant="outline" className="gap-0.5 text-[10px] px-1.5 py-0 hover:bg-muted cursor-pointer">
<Users className="h-3 w-3" />
{link.team_name || "Team"}
</Badge>
</Link>
)}
</div>
{link.target_description && link.source_agent_id === agentId && (
<p className="truncate text-xs text-muted-foreground">
{link.target_description}
</p>
)}
{link.description && (
<p className="truncate text-xs text-muted-foreground/70">
{link.description}
</p>
)}
</div>
<Badge variant={directionBadgeVariant(link.direction)}>
{link.direction}
</Badge>
<Switch
checked={link.status === "active"}
onCheckedChange={() => onStatusToggle(link)}
/>
<span className="text-sm text-muted-foreground">
{link.max_concurrent}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(link)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onDelete(link)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,41 @@
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
export const DIRECTION_OPTIONS = [
{ value: "outbound", label: "Outbound", desc: "This agent can delegate to the target" },
{ value: "inbound", label: "Inbound", desc: "The target can delegate to this agent" },
{ value: "bidirectional", label: "Bidirectional", desc: "Both agents can delegate to each other" },
] as const;
export function directionBadgeVariant(direction: string) {
switch (direction) {
case "outbound": return "info" as const;
case "inbound": return "warning" as const;
case "bidirectional": return "success" as const;
default: return "outline" as const;
}
}
export function parseCommaSeparated(val: string): string[] {
return val.split(",").map((s) => s.trim()).filter(Boolean);
}
export function joinArray(arr?: string[]): string {
return (arr ?? []).join(", ");
}
export function linkTargetName(link: AgentLinkData, currentAgentId: string): string {
if (link.source_agent_id === currentAgentId) {
return link.target_display_name || link.target_agent_key || link.target_agent_id;
}
return link.source_agent_key || link.source_agent_id;
}
export function buildSettings(userAllow: string, userDeny: string): AgentLinkSettings | undefined {
const allow = parseCommaSeparated(userAllow);
const deny = parseCommaSeparated(userDeny);
if (allow.length === 0 && deny.length === 0) return undefined;
return {
...(allow.length > 0 ? { user_allow: allow } : {}),
...(deny.length > 0 ? { user_deny: deny } : {}),
};
}
@@ -0,0 +1,65 @@
import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
export function useAgentLinks(agentId: string) {
const ws = useWs();
const [links, setLinks] = useState<AgentLinkData[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const res = await ws.call<{ links: AgentLinkData[]; count: number }>(
Methods.AGENTS_LINKS_LIST,
{ agentId, direction: "all" },
);
setLinks(res.links ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws, agentId]);
const createLink = useCallback(
async (params: {
targetAgent: string;
direction: string;
description?: string;
maxConcurrent?: number;
settings?: AgentLinkSettings;
}) => {
await ws.call(Methods.AGENTS_LINKS_CREATE, {
sourceAgent: agentId,
targetAgent: params.targetAgent,
direction: params.direction,
description: params.description,
maxConcurrent: params.maxConcurrent,
settings: params.settings,
});
load();
},
[ws, agentId, load],
);
const updateLink = useCallback(
async (linkId: string, updates: Record<string, unknown>) => {
await ws.call(Methods.AGENTS_LINKS_UPDATE, { linkId, ...updates });
load();
},
[ws, load],
);
const deleteLink = useCallback(
async (linkId: string) => {
await ws.call(Methods.AGENTS_LINKS_DELETE, { linkId });
load();
},
[ws, load],
);
return { links, loading, load, createLink, updateLink, deleteLink };
}
@@ -101,7 +101,7 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
<div key={ch} className="rounded-md border">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={() => toggle(ch)}
>
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
@@ -99,7 +99,7 @@ export function ProvidersSection({ data, onSave, saving }: Props) {
<div key={p.key} className="rounded-md border">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={() => toggle(p.key)}
>
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
@@ -203,7 +203,7 @@ function SystemMessageBlock({ content }: { content: string }) {
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-1 text-primary hover:underline"
className="ml-1 cursor-pointer text-primary hover:underline"
>
{expanded ? "hide" : "show details"}
</button>
@@ -246,7 +246,7 @@ function SummaryBlock({ text }: { text: string }) {
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-xs font-medium text-primary hover:underline"
className="mt-1 cursor-pointer text-xs font-medium text-primary hover:underline"
>
{expanded ? "Show less" : "Show more"}
</button>
+70
View File
@@ -0,0 +1,70 @@
import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { TeamData, TeamMemberData, TeamTaskData } from "@/types/team";
export function useTeams() {
const ws = useWs();
const [teams, setTeams] = useState<TeamData[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!ws.isConnected) return;
setLoading(true);
try {
const res = await ws.call<{ teams: TeamData[]; count: number }>(
Methods.TEAMS_LIST,
);
setTeams(res.teams ?? []);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [ws]);
const createTeam = useCallback(
async (params: {
name: string;
lead: string;
members: string[];
description?: string;
}) => {
await ws.call(Methods.TEAMS_CREATE, params);
load();
},
[ws, load],
);
const deleteTeam = useCallback(
async (teamId: string) => {
await ws.call(Methods.TEAMS_DELETE, { teamId });
load();
},
[ws, load],
);
const getTeam = useCallback(
async (teamId: string) => {
const res = await ws.call<{ team: TeamData; members: TeamMemberData[] }>(
Methods.TEAMS_GET,
{ teamId },
);
return res;
},
[ws],
);
const getTeamTasks = useCallback(
async (teamId: string) => {
const res = await ws.call<{ tasks: TeamTaskData[]; count: number }>(
Methods.TEAMS_TASK_LIST,
{ teamId },
);
return res;
},
[ws],
);
return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks };
}
@@ -0,0 +1,2 @@
export { MemberList } from "./member-list";
export { roleBadgeVariant } from "./member-utils";
@@ -0,0 +1,58 @@
import { Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { TeamMemberData } from "@/types/team";
import { roleBadgeVariant } from "./member-utils";
interface MemberListProps {
members: TeamMemberData[];
}
export function MemberList({ members }: MemberListProps) {
if (members.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Users className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No members</p>
</div>
);
}
return (
<div className="rounded-lg border">
<div className="grid grid-cols-[1fr_1fr_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Agent</span>
<span>Frontmatter</span>
<span>Role</span>
</div>
{members.map((member) => (
<div
key={member.agent_id}
className="grid grid-cols-[1fr_1fr_80px] items-center gap-2 border-b px-4 py-3 last:border-0"
>
<div className="min-w-0">
<span className="truncate text-sm font-medium">
{member.display_name || member.agent_key || member.agent_id.slice(0, 8)}
</span>
{member.display_name && member.agent_key && (
<p className="truncate text-xs text-muted-foreground">
{member.agent_key}
</p>
)}
</div>
<div className="min-w-0">
{member.frontmatter ? (
<p className="line-clamp-2 text-xs text-muted-foreground/70">
{member.frontmatter}
</p>
) : (
<span className="text-xs text-muted-foreground/40">—</span>
)}
</div>
<Badge variant={roleBadgeVariant(member.role)}>
{member.role}
</Badge>
</div>
))}
</div>
);
}
@@ -0,0 +1,7 @@
export function roleBadgeVariant(role: string) {
switch (role) {
case "lead": return "info" as const;
case "member": return "outline" as const;
default: return "outline" as const;
}
}
@@ -0,0 +1,2 @@
export { TaskList } from "./task-list";
export { taskStatusBadgeVariant } from "./task-utils";
@@ -0,0 +1,71 @@
import { ClipboardList } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { TeamTaskData } from "@/types/team";
import { taskStatusBadgeVariant } from "./task-utils";
interface TaskListProps {
tasks: TeamTaskData[];
loading: boolean;
}
export function TaskList({ tasks, loading }: TaskListProps) {
if (loading && tasks.length === 0) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading tasks...
</div>
);
}
if (tasks.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No tasks yet</p>
<p className="text-xs text-muted-foreground">
Tasks are created by team agents during collaboration.
</p>
</div>
);
}
return (
<div className="rounded-lg border">
<div className="grid grid-cols-[1fr_90px_100px_60px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Subject</span>
<span>Status</span>
<span>Owner</span>
<span>Priority</span>
</div>
{tasks.map((task) => (
<div
key={task.id}
className="grid grid-cols-[1fr_90px_100px_60px] items-center gap-2 border-b px-4 py-3 last:border-0"
>
<div className="min-w-0">
<span className="truncate text-sm font-medium">{task.subject}</span>
{task.description && (
<p className="truncate text-xs text-muted-foreground/70">
{task.description}
</p>
)}
{task.result && (
<p className="mt-0.5 line-clamp-1 text-xs text-emerald-600 dark:text-emerald-400">
{task.result}
</p>
)}
</div>
<Badge variant={taskStatusBadgeVariant(task.status)}>
{task.status.replace("_", " ")}
</Badge>
<span className="truncate text-sm text-muted-foreground">
{task.owner_agent_key || "—"}
</span>
<span className="text-sm text-muted-foreground">
{task.priority}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,9 @@
export function taskStatusBadgeVariant(status: string) {
switch (status) {
case "pending": return "outline" as const;
case "in_progress": return "info" as const;
case "completed": return "success" as const;
case "blocked": return "warning" as const;
default: return "outline" as const;
}
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more