diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4f6145beb..c2d320c59 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write diff --git a/CLAUDE.md b/CLAUDE.md index fd46e2684..d1647ebf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,36 +4,45 @@ PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API. ## Tech Stack -**Backend:** Go 1.25, Cobra CLI, gorilla/websocket, pgx/v5 (database/sql, no ORM), golang-migrate, go-rod/rod, telego (Telegram) +**Backend:** Go 1.26, Cobra CLI, gorilla/websocket, pgx/v5 (database/sql, no ORM), golang-migrate, go-rod/rod, telego (Telegram) **Web UI:** React 19, Vite 6, TypeScript, Tailwind CSS 4, Radix UI, Zustand, React Router 7. Located in `ui/web/`. **Use `pnpm` (not npm).** -**Database:** PostgreSQL 15+ with pgvector. Raw SQL with `$1, $2` positional params. Nullable columns: `*string`, `*time.Time`, etc. +**Database:** PostgreSQL 18 with pgvector. Raw SQL with `$1, $2` positional params. Nullable columns: `*string`, `*time.Time`, etc. ## Project Structure ``` cmd/ CLI commands, gateway startup, onboard wizard, migrations internal/ -├── gateway/ WS + HTTP server, client, method router -│ └── methods/ RPC handlers (chat, agents, sessions, config, skills, cron, pairing) ├── agent/ Agent loop (think→act→observe), router, resolver, input guard -├── providers/ LLM providers: Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE) -├── tools/ Tool registry, filesystem, exec, web, memory, subagent, MCP bridge -├── store/ Store interfaces + pg/ (PostgreSQL) implementations ├── bootstrap/ System prompt files (SOUL.md, IDENTITY.md) + seeding + per-user seed -├── config/ Config loading (JSON5) + env var overlay +├── bus/ Event bus system +├── cache/ Caching layer ├── channels/ Channel manager: Telegram, Feishu/Lark, Zalo, Discord, WhatsApp +├── config/ Config loading (JSON5) + env var overlay +├── crypto/ AES-256-GCM encryption for API keys +├── cron/ Cron scheduling (at/every/cron expr) +├── gateway/ WS + HTTP server, client, method router +│ └── methods/ RPC handlers (chat, agents, sessions, config, skills, cron, pairing) +├── hooks/ Hook system for extensibility ├── http/ HTTP API (/v1/chat/completions, /v1/agents, /v1/skills, etc.) -├── skills/ SKILL.md loader + BM25 search +├── i18n/ Message catalog: T(locale, key, args...) + per-locale catalogs (en/vi/zh) +├── knowledgegraph/ Knowledge graph storage and traversal +├── mcp/ Model Context Protocol bridge/server +├── media/ Media handling utilities ├── memory/ Memory system (pgvector) -├── tracing/ LLM call tracing + optional OTel export (build-tag gated) -├── scheduler/ Lane-based concurrency (main/subagent/cron) -├── cron/ Cron scheduling (at/every/cron expr) +├── oauth/ OAuth authentication ├── permissions/ RBAC (admin/operator/viewer) -├── pairing/ Browser pairing (8-char codes) -├── crypto/ AES-256-GCM encryption for API keys +├── providers/ LLM providers: Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI) ├── sandbox/ Docker-based code sandbox +├── scheduler/ Lane-based concurrency (main/subagent/cron) +├── sessions/ Session management +├── skills/ SKILL.md loader + BM25 search +├── store/ Store interfaces + pg/ (PostgreSQL) implementations +├── tasks/ Task management +├── tools/ Tool registry, filesystem, exec, web, memory, subagent, MCP bridge +├── tracing/ LLM call tracing + optional OTel export (build-tag gated) ├── tts/ Text-to-Speech (OpenAI, ElevenLabs, Edge, MiniMax) -├── i18n/ Message catalog: T(locale, key, args...) + per-locale catalogs (en/vi/zh) +├── upgrade/ Database schema version tracking pkg/protocol/ Wire types (frames, methods, errors, events) pkg/browser/ Browser automation (Rod + CDP) migrations/ PostgreSQL migration files @@ -45,7 +54,7 @@ ui/web/ React SPA (pnpm, Vite, Tailwind, Radix UI) - **Store layer:** Interface-based (`store.SessionStore`, `store.AgentStore`, etc.) with pg/ (PostgreSQL) implementations. Uses `database/sql` + `pgx/v5/stdlib`, raw SQL, `execMapUpdate()` helper in `pg/helpers.go` - **Agent types:** `open` (per-user context, 7 files) vs `predefined` (shared context + USER.md per-user) - **Context files:** `agent_context_files` (agent-level) + `user_context_files` (per-user), routed via `ContextFileInterceptor` -- **Providers:** Anthropic (native HTTP+SSE) and OpenAI-compat (generic). Both use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys +- **Providers:** Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI). All use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys - **Agent loop:** `RunRequest` → think→act→observe → `RunResult`. Events: `run.started`, `run.completed`, `chunk`, `tool.call`, `tool.result`. Auto-summarization at >75% context - **Context propagation:** `store.WithAgentType(ctx)`, `store.WithUserID(ctx)`, `store.WithAgentID(ctx)`, `store.WithLocale(ctx)` - **WebSocket protocol (v3):** Frame types `req`/`res`/`event`. First request must be `connect` diff --git a/Dockerfile b/Dockerfile index fbbf5d376..d0abc67f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,8 @@ RUN set -eux; \ apk add --no-cache python3 py3-pip nodejs npm pandoc github-cli doas; \ echo "permit nopass goclaw as root cmd apk" > /etc/doas.d/goclaw.conf; \ pip3 install --no-cache-dir --break-system-packages \ - pypdf openpyxl pandas python-pptx markitdown defusedxml lxml; \ + pypdf openpyxl pandas python-pptx markitdown defusedxml lxml \ + pdf2image pdfplumber anthropic; \ npm install -g --cache /tmp/npm-cache docx pptxgenjs; \ rm -rf /tmp/npm-cache /root/.cache /var/cache/apk/*; \ else \ diff --git a/README.md b/README.md index eb9472aaa..bfa19fd1a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # GoClaw -[![Go](https://img.shields.io/badge/Go_1.25-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-316192?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)](https://www.docker.com/) [![WebSocket](https://img.shields.io/badge/WebSocket-010101?style=flat-square&logo=socket.io&logoColor=white)](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) [![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-000000?style=flat-square&logo=opentelemetry&logoColor=white)](https://opentelemetry.io/) [![Anthropic](https://img.shields.io/badge/Anthropic-191919?style=flat-square&logo=anthropic&logoColor=white)](https://www.anthropic.com/) [![OpenAI](https://img.shields.io/badge/OpenAI_Compatible-412991?style=flat-square&logo=openai&logoColor=white)](https://openai.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) +[![Go](https://img.shields.io/badge/Go_1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-316192?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)](https://www.docker.com/) [![WebSocket](https://img.shields.io/badge/WebSocket-010101?style=flat-square&logo=socket.io&logoColor=white)](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) [![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-000000?style=flat-square&logo=opentelemetry&logoColor=white)](https://opentelemetry.io/) [![Anthropic](https://img.shields.io/badge/Anthropic-191919?style=flat-square&logo=anthropic&logoColor=white)](https://www.anthropic.com/) [![OpenAI](https://img.shields.io/badge/OpenAI_Compatible-412991?style=flat-square&logo=openai&logoColor=white)](https://openai.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) **GoClaw** is a multi-agent AI gateway that connects LLMs to your tools, channels, and data — deployed as a single Go binary with zero runtime dependencies. It orchestrates agent teams, inter-agent delegation, and quality-gated workflows across 13+ LLM providers with full multi-tenant isolation. @@ -12,7 +12,7 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu ## What Makes It Different -- **Agent Teams & Orchestration** — Teams with shared task boards, inter-agent delegation (sync/async), conversation handoff, evaluate-loop quality gates, and hybrid agent discovery +- **Agent Teams & Orchestration** — Teams with shared task boards, inter-agent delegation (sync/async), and hybrid agent discovery - **Multi-Tenant PostgreSQL** — Per-user workspaces, per-user context files, encrypted API keys (AES-256-GCM), isolated sessions — the only Claw project with DB-native multi-tenancy - **Single Binary** — ~25 MB static Go binary, no Node.js runtime, <1s startup, runs on a $5 VPS - **Production Security** — 5-layer defense: rate limiting, prompt injection detection, SSRF protection, shell deny patterns, AES-256-GCM encryption @@ -41,8 +41,6 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu | Hooks system | — | — | — | ✅ Command + agent evaluators | | MCP integration | — (uses ACP) | — | — | ✅ (stdio/SSE/streamable-http) | | Agent teams | — | — | — | ✅ Task board + mailbox | -| Agent handoff | — | — | — | ✅ Conversation transfer | -| Evaluate loop | — | — | — | ✅ Generator-evaluator cycle | | Quality gates | — | — | — | ✅ Hook-based validation | | Security hardening | ✅ (SSRF, path traversal, injection) | ✅ (sandbox, rate limit, injection, pairing) | Basic (workspace restrict, exec deny) | ✅ 5-layer defense | | OTel observability | ✅ (opt-in extension) | ✅ (Prometheus + OTLP) | — | ✅ OTLP (opt-in build tag) | @@ -57,7 +55,7 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu | Per-user workspaces | ✅ (file-based) | — | — | ✅ (PostgreSQL) | | Encrypted secrets | — (env vars only) | ✅ ChaCha20-Poly1305 | — (plaintext JSON) | ✅ AES-256-GCM in DB | -> **GoClaw unique strengths:** Only project with multi-tenant PostgreSQL, agent teams, conversation handoff, evaluate-loop quality gates, hooks system, knowledge graph, and MCP protocol support. +> **GoClaw unique strengths:** Only project with multi-tenant PostgreSQL, agent teams, hooks system, knowledge graph, and MCP protocol support. ## Architecture @@ -168,7 +166,7 @@ agents.links.create { **Per-User Restrictions** — The `settings` JSONB on agent links supports per-user deny/allow lists. -**Agent Discovery** — Each agent has a `frontmatter` field for discovery. With ≤15 targets, auto-generated `AGENTS.md` is injected into context. With >15 targets, agents use `delegate_search` for hybrid FTS + semantic search. +**Agent Discovery** — Each agent has a `frontmatter` field for discovery. With ≤15 targets, auto-generated `AGENTS.md` is injected into context. Delegation uses subagent spawning for larger target sets.
Delegation vs Subagents @@ -231,47 +229,6 @@ flowchart TD - **Team mailbox** — Direct peer-to-peer messaging (send, broadcast, read unread) - **Tools**: `team_tasks` for task management, `team_message` for mailbox -### Agent Handoff - -Handoff transfers conversation control from one agent to another. Unlike delegation (where A stays in control), handoff means B completely takes over the user conversation. - -```mermaid -flowchart LR - subgraph Delegation["Delegation (A stays in control)"] - direction TB - DA["Agent A"] -->|"delegate task"| DB["Agent B"] - DB -->|"return result"| DA - DA -->|"reply to user"| DU((User)) - end - - subgraph Handoff["Handoff (B takes over)"] - direction TB - HA["Agent A"] -->|"handoff"| HB["Agent B"] - HB -->|"now handles user"| HU((User)) - end -``` - -- **Routing override** — Sets a routing rule so all future messages go to the target agent -- **Context transfer** — Conversation context is passed to the new agent -- **Revert** — `handoff(action="clear")` returns routing to the original agent - -### Evaluate Loop - -The evaluate loop orchestrates a generator-evaluator feedback cycle between two agents for quality-gated output. - -```mermaid -flowchart LR - TASK["Task + Criteria"] --> GEN["Generator
Agent"] - GEN -->|"output"| EVAL{"Evaluator
Agent"} - EVAL -->|"APPROVED"| RESULT["Final Output"] - EVAL -->|"REJECTED + feedback"| GEN - EVAL -.->|"max rounds hit"| WARN["Last output + warning"] -``` - -- **Configurable rounds** — Default 3, max 5 revision cycles -- **Custom pass criteria** — Define what "approved" means for the evaluator -- **Tool**: `evaluate_loop(generator="writer-bot", evaluator="qa-bot", task="...", pass_criteria="...")` - ### Quality Gates Quality gates validate agent output before it reaches users. Configured in agent `other_config`: @@ -307,14 +264,12 @@ Quality gates validate agent output before it reaches users. Configured in agent - **Subagents** — Spawn child agents with different models for parallel task execution - **Agent delegation** — Sync/async inter-agent task delegation with permission links, concurrency limits, and per-user restrictions - **Agent teams** — Shared task boards with dependencies, team mailbox, and coordinated multi-agent workflows -- **Agent handoff** — Transfer conversation control between agents with routing overrides -- **Evaluate loop** — Generator-evaluator feedback cycles for quality-gated output - **Quality gates** — Hook-based output validation with command or agent evaluators - **Delegation history** — Queryable audit trail of all inter-agent delegations - **Concurrent execution** — Lane-based scheduler (main/subagent/delegate/cron), adaptive throttle for group chats ### Tools & Integrations -- **30+ built-in tools** — File system, shell exec, web search/fetch, memory, browser automation, TTS, and more +- **60+ built-in tools** — File system, shell exec, web search/fetch, memory, browser automation, TTS, and more - **MCP integration** — Connect external MCP servers via stdio, SSE, or streamable-http with per-agent/per-user grants - **Hooks system** — Event-driven hooks with command evaluators (shell exit code) and agent evaluators (delegate to reviewer) for output validation @@ -436,7 +391,7 @@ export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) - Per-user context files and workspaces (`user_context_files` table) - Agent types: `open` (per-user workspace) vs `predefined` (shared context) -- Agent teams, delegation, handoff, evaluate loops, quality gates +- Agent teams, delegation, quality gates - LLM call tracing with spans and prompt cache metrics - MCP server integration with per-agent and per-user access grants - Event-driven hooks for agent lifecycle with command and agent evaluators @@ -448,7 +403,7 @@ export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) ### Prerequisites -- Go 1.25+ +- Go 1.26+ - PostgreSQL 18 with pgvector - Docker (optional, for sandbox and containerized deployment) @@ -823,9 +778,6 @@ This creates `.env` with `GOCLAW_ENCRYPTION_KEY` and `GOCLAW_GATEWAY_TOKEN` pre- | `spawn` | — | Spawn a subagent | | `subagents` | sessions | Control running subagents | | `delegate` | orchestration | Delegate tasks to other agents (sync/async, cancel, list) | -| `delegate_search` | orchestration | Search delegation targets (hybrid FTS + semantic) | -| `handoff` | orchestration | Transfer conversation control to another agent | -| `evaluate_loop` | orchestration | Generate-evaluate-revise quality feedback loop | | `team_tasks` | teams | Shared task board (list, create, claim, complete, search) | | `team_message` | teams | Team mailbox (send, broadcast, read) | | `sessions_list` | sessions | List active sessions | @@ -961,7 +913,6 @@ GOCLAW_OPENROUTER_API_KEY=sk-or-xxx go test -v ./tests/integration/ -timeout 120 ### Implemented but Not Fully Tested -- **Agent handoff** — Conversation transfer between agents with routing overrides. Implementation complete, needs E2E testing. - **Quality gates** — Hook-based output validation with command and agent evaluator types. Implementation complete, needs E2E testing. - **Slack** — Channel integration implemented, not yet validated with real users. - **Other messaging channels** — Discord, Zalo OA, Zalo Personal, 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. diff --git a/cmd/gateway.go b/cmd/gateway.go index 9e95800cd..b6a3ef8d5 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -354,6 +354,11 @@ func runGateway() { httpapi.InitAPIKeyCache(pgStores.APIKeys, msgBus) } + // Allow browser-paired users to access HTTP APIs + if pgStores.Pairing != nil { + httpapi.InitPairingAuth(pgStores.Pairing) + } + // Memory management API (wired directly, only needs MemoryStore + token) if pgStores != nil && pgStores.Memory != nil { server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token)) @@ -505,6 +510,112 @@ func runGateway() { slog.Info("team task event subscriber registered") } + // Team progress notification subscriber — forwards task events to chat channels. + // Reads team.settings.notifications config; direct mode sends outbound, leader mode + // injects into leader agent session. + if pgStores.Teams != nil { + notifyTeamStore := pgStores.Teams + notifyAgentStore := pgStores.Agents + msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) { + payload, ok := evt.Payload.(protocol.TeamTaskEventPayload) + if !ok || payload.TeamID == "" || payload.Channel == "" { + return + } + // Only forward assigned/failed events (completed handled by announce-back). + var notifyType string + switch evt.Name { + case protocol.EventTeamTaskAssigned: + notifyType = "dispatched" + case protocol.EventTeamTaskFailed: + notifyType = "failed" + case protocol.EventTeamTaskProgress: + notifyType = "progress" + default: + return + } + + teamUUID, err := uuid.Parse(payload.TeamID) + if err != nil { + return + } + team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID) + if err != nil || team == nil { + return + } + cfg := tools.ParseTeamNotifyConfig(team.Settings) + + // Check if this notification type is enabled. + switch notifyType { + case "dispatched": + if !cfg.Dispatched { + return + } + case "failed": + if !cfg.Failed { + return + } + case "progress": + if !cfg.Progress { + return + } + } + + // Skip internal channels. + if payload.Channel == tools.ChannelSystem || payload.Channel == tools.ChannelDelegate { + return + } + + // Build notification message. + var content string + agentName := payload.OwnerAgentKey + if payload.OwnerDisplayName != "" { + agentName = payload.OwnerDisplayName + } + switch notifyType { + case "dispatched": + content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName) + case "progress": + content = fmt.Sprintf("⏳ Task #%d: %d%% — %s", payload.TaskNumber, payload.ProgressPercent, payload.ProgressStep) + case "failed": + reason := payload.Reason + if len(reason) > 200 { + reason = reason[:200] + "..." + } + content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason) + } + + if cfg.Mode == "leader" { + // Route through leader agent — model reformulates. + leadAgent := "" + if notifyAgentStore != nil { + if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { + leadAgent = la.AgentKey + } + } + if leadAgent == "" { + return + } + leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content) + msgBus.TryPublishInbound(bus.InboundMessage{ + Channel: payload.Channel, + SenderID: "notification:progress", + ChatID: payload.ChatID, + AgentID: leadAgent, + UserID: payload.UserID, + Content: leaderContent, + }) + } else { + // Direct mode — send outbound directly to channel. + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: payload.Channel, + ChatID: payload.ChatID, + Content: content, + }) + } + }) + slog.Info("team progress notification subscriber registered") + } + // Setup graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 73dbdba69..841c41493 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -106,20 +106,6 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "use_skill", DisplayName: "Use Skill", Description: "Activate a skill to use its specialized capabilities (tracing marker)", Category: "skills", Enabled: true}, {Name: "publish_skill", DisplayName: "Publish Skill", Description: "Register a skill directory (created via skill-creator) in the system database, making it discoverable and grantable to agents", Category: "skills", Enabled: true}, - // delegation (deprecated — team_tasks is the coordination mechanism now) - {Name: "delegate_search", DisplayName: "Delegate Search", Description: "Search for available delegation targets by keyword (deprecated)", Category: "delegation", Enabled: false, - Requires: []string{"managed_mode", "agent_links"}, - Metadata: json.RawMessage(`{"deprecated":true}`), - }, - {Name: "evaluate_loop", DisplayName: "Evaluate Loop", Description: "Run a generate→evaluate→revise loop between two agents (deprecated)", Category: "delegation", Enabled: false, - Requires: []string{"managed_mode", "agent_links"}, - Metadata: json.RawMessage(`{"deprecated":true}`), - }, - {Name: "handoff", DisplayName: "Handoff", Description: "Transfer the conversation to another agent (deprecated)", Category: "delegation", Enabled: false, - Requires: []string{"managed_mode", "agent_links"}, - Metadata: json.RawMessage(`{"deprecated":true}`), - }, - // teams {Name: "team_tasks", DisplayName: "Team Tasks", Description: "View, create, update, and complete tasks on the team task board", Category: "teams", Enabled: true, Requires: []string{"managed_mode", "teams"}, @@ -127,12 +113,6 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "team_message", DisplayName: "Team Message", Description: "Send a direct message or broadcast to teammates in the agent team", Category: "teams", Enabled: true, Requires: []string{"managed_mode", "teams"}, }, - {Name: "workspace_write", DisplayName: "Workspace Write", Description: "Write files to the team shared workspace visible to all team members", Category: "teams", Enabled: true, - Requires: []string{"managed_mode", "teams"}, - }, - {Name: "workspace_read", DisplayName: "Workspace Read", Description: "Read, list, delete, pin, and tag files in the team shared workspace", Category: "teams", Enabled: true, - Requires: []string{"managed_mode", "teams"}, - }, } } diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2ff838bcb..e2649f055 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -150,6 +150,11 @@ func wireChannelEventSubscribers( // Wire pairing approval notification → channel (matching TS notifyPairingApproved). botName := cfg.ResolveDisplayName("default") pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) { + // Browser/internal channels use WebSocket — UI polls approval status directly. + if channels.IsInternalChannel(channel) { + slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel) + return + } msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName) // Group pairings need group_id metadata so channels (e.g. Zalo) route to group API. if strings.HasPrefix(senderID, "group:") { diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index b6426ceb4..abbd32bdd 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -101,9 +101,6 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents if handleDelegateAnnounce(ctx, msg, cfg, sched, channelMgr, msgBus, getAnnounceMu) { continue } - if handleHandoffAnnounce(ctx, msg, cfg, sched, channelMgr, msgBus) { - continue - } if handleTeammateMessage(ctx, msg, cfg, sched, channelMgr, teamStore, agentStore, msgBus, postTurn, &taskRunSessions) { continue } diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 52ca6cd0e..409e46c37 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -309,88 +309,6 @@ func handleDelegateAnnounce( return true } -// handleHandoffAnnounce processes handoff announce messages: route initial message -// to target agent session using the "delegate" lane. -// Returns true if the message was handled (caller should continue). -func handleHandoffAnnounce( - ctx context.Context, - msg bus.InboundMessage, - cfg *config.Config, - sched *scheduler.Scheduler, - channelMgr *channels.Manager, - msgBus *bus.MessageBus, -) bool { - if !(msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "handoff:")) { - return false - } - - origChannel := msg.Metadata["origin_channel"] - origPeerKind := msg.Metadata["origin_peer_kind"] - origLocalKey := msg.Metadata["origin_local_key"] - origChannelType := resolveChannelType(channelMgr, origChannel) - targetAgent := msg.AgentID - if targetAgent == "" { - targetAgent = cfg.ResolveDefaultAgentID() - } - if origPeerKind == "" { - origPeerKind = string(sessions.PeerDirect) - } - - if origChannel == "" || msg.ChatID == "" { - slog.Warn("handoff announce: missing origin", "sender", msg.SenderID) - return true - } - - sessionKey := sessions.BuildScopedSessionKey(targetAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey) - sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, targetAgent, origChannel, msg.ChatID, origPeerKind) - - slog.Info("handoff announce → scheduler (delegate lane)", - "handoff", msg.SenderID, - "to", targetAgent, - "session", sessionKey, - ) - - announceUserID := msg.UserID - if origPeerKind == string(sessions.PeerGroup) && msg.ChatID != "" { - announceUserID = fmt.Sprintf("group:%s:%s", origChannel, msg.ChatID) - } - - outMeta := buildAnnounceOutMeta(origLocalKey) - - outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{ - SessionKey: sessionKey, - Message: msg.Content, - Channel: origChannel, - ChannelType: origChannelType, - ChatID: msg.ChatID, - PeerKind: origPeerKind, - LocalKey: origLocalKey, - UserID: announceUserID, - RunID: fmt.Sprintf("handoff-%s", msg.Metadata["handoff_id"]), - Stream: false, - }) - - go func(origCh, chatID string, meta map[string]string) { - outcome := <-outCh - if outcome.Err != nil { - slog.Error("handoff announce: agent run failed", "error", outcome.Err) - return - } - if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) { - return - } - outMsg := bus.OutboundMessage{ - Channel: origCh, - ChatID: chatID, - Content: outcome.Result.Content, - Metadata: meta, - } - appendMediaToOutbound(&outMsg, outcome.Result.Media) - msgBus.PublishOutbound(outMsg) - }(origChannel, msg.ChatID, outMeta) - - return true -} // handleTeammateMessage processes teammate messages: bypass debounce, route to target // agent session using the "delegate" lane, then announce result back to lead. @@ -548,6 +466,21 @@ func handleTeammateMessage( if !alreadyTerminal { toAgent := inMeta["to_agent"] now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + // Enrich event payload with task details for notifications. + taskSubject := "" + taskNumber := 0 + taskChannel := inMeta["origin_channel"] + taskChatID := inMeta["origin_chat_id"] + if currentTask != nil { + taskSubject = currentTask.Subject + taskNumber = currentTask.TaskNumber + if currentTask.Channel != "" { + taskChannel = currentTask.Channel + } + if currentTask.ChatID != "" { + taskChatID = currentTask.ChatID + } + } if outcome.Err != nil { if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil { slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err) @@ -555,18 +488,19 @@ func handleTeammateMessage( msgBus.Broadcast(bus.Event{ Name: protocol.EventTeamTaskFailed, Payload: protocol.TeamTaskEventPayload{ - TeamID: teamID.String(), - TaskID: teamTaskID.String(), - Status: store.TeamTaskStatusFailed, - Timestamp: now, - ActorType: "agent", - ActorID: toAgent, + TeamID: teamID.String(), + TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, + Status: store.TeamTaskStatusFailed, + Reason: outcome.Err.Error(), + Channel: taskChannel, + ChatID: taskChatID, + Timestamp: now, + ActorType: "agent", + ActorID: toAgent, }, }) - // FailTask also unblocks dependent tasks. - if postTurn != nil { - postTurn.DispatchUnblockedTasks(ctx, teamID) - } } } else { result := outcome.Result.Content @@ -584,20 +518,26 @@ func handleTeammateMessage( Payload: protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, Status: store.TeamTaskStatusCompleted, OwnerAgentKey: toAgent, + Channel: taskChannel, + ChatID: taskChatID, Timestamp: now, ActorType: "agent", ActorID: toAgent, }, }) - // Dispatch newly-unblocked dependent tasks. - if postTurn != nil { - postTurn.DispatchUnblockedTasks(ctx, teamID) - } } } } + // Always dispatch unblocked tasks after member turn ends, + // regardless of whether the task was already completed by the tool. + // This ensures dependent tasks start only after the member's run finishes. + if postTurn != nil { + postTurn.DispatchUnblockedTasks(ctx, teamID) + } } } } @@ -645,14 +585,25 @@ func handleTeammateMessage( } memberAgent := inMeta["to_agent"] + // Build task board snapshot scoped to this batch (same origin_trace_id). + taskBoardSnapshot := "" + if teamIDStr := inMeta["team_id"]; teamIDStr != "" { + if teamUUID, err := uuid.Parse(teamIDStr); err == nil { + taskBoardSnapshot = buildTaskBoardSnapshot(ctx, teamStore, teamUUID, inMeta["origin_chat_id"], inMeta["origin_trace_id"]) + } + } + announceContent := fmt.Sprintf( - "[System Message] Team member %q completed task.\n\nResult:\n%s\n\n"+ - "Present this result to the user. Any media files are forwarded automatically. Do NOT search for files — the result above contains all relevant information.", + "[System Message] Team member %q completed task.\n\nResult:\n%s", memberAgent, outcome.Result.Content, ) + if taskBoardSnapshot != "" { + announceContent += "\n\n" + taskBoardSnapshot + } + announceContent += "\n\nPresent this result to the user. Any media files are forwarded automatically. Do NOT search for files — the result above contains all relevant information." // Append team workspace path so lead can locate files without searching. if ws := inMeta["team_workspace"]; ws != "" { - announceContent += fmt.Sprintf("\n[Team workspace: %s — use read_file with path relative to workspace root, e.g. read_file(path=\"teams/...\")]", ws) + announceContent += fmt.Sprintf("\n[Team workspace: %s — use read_file/list_files to access shared files, e.g. list_files(path=\".\") then read_file(path=\"filename.md\")]", ws) } // Route to the lead's session on the original channel/chat. @@ -699,6 +650,9 @@ func handleTeammateMessage( announceOutCh := sched.Schedule(announceCtx, scheduler.LaneSubagent, announceReq) announceOutcome := <-announceOutCh + // Release team create lock — tasks already visible in DB, safe for other goroutines to list. + announcePtd.ReleaseTeamLock() + // Post-turn: dispatch pending team tasks created during announce. if postTurn != nil { for tid, tIDs := range announcePtd.Drain() { @@ -845,3 +799,47 @@ func handleStopCommand( return true } + +// buildTaskBoardSnapshot returns a formatted summary of batch task statuses +// for inclusion in the announce message to the leader. Scoped by (teamID, chatID) +// and filtered by origin_trace_id to show only tasks from the current batch. +func buildTaskBoardSnapshot(ctx context.Context, teamStore store.TeamStore, teamID uuid.UUID, chatID, originTraceID string) string { + if teamStore == nil || originTraceID == "" { + return "" + } + // Shared workspace: show all tasks across chats. + snapshotChatID := chatID + if team, err := teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + snapshotChatID = "" + } + allTasks, err := teamStore.ListTasks(ctx, teamID, "", store.TeamTaskFilterAll, "", "", snapshotChatID, 0) + if err != nil || len(allTasks) == 0 { + return "" + } + + // Filter to current batch by origin_trace_id stored in task metadata. + var active, completed int + var activeLines []string + for _, t := range allTasks { + tid, _ := t.Metadata["origin_trace_id"].(string) + if tid != originTraceID { + continue + } + switch t.Status { + case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed: + completed++ + default: + active++ + activeLines = append(activeLines, fmt.Sprintf(" #%d %s — %s", t.TaskNumber, t.Subject, t.Status)) + } + } + total := active + completed + if total == 0 { + return "" + } + if active == 0 { + return fmt.Sprintf("=== Task board (this batch) ===\nAll %d tasks completed.", total) + } + return fmt.Sprintf("=== Task board (this batch) ===\nTask progress: %d/%d completed, %d active:\n%s", + completed, total, active, strings.Join(activeLines, "\n")) +} diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index cc2b53d85..9d6536d04 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -43,15 +43,6 @@ func processNormalMessage( agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind) } - // Check handoff routing override - if teamStore != nil && msg.AgentID == "" { - if route, _ := teamStore.GetHandoffRoute(ctx, msg.Channel, msg.ChatID); route != nil { - agentID = route.ToAgentKey - slog.Info("inbound: handoff route active", - "channel", msg.Channel, "chat", msg.ChatID, "to", agentID) - } - } - agentLoop, err := agents.Get(agentID) if err != nil { slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel) @@ -333,6 +324,9 @@ func processNormalMessage( go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) { outcome := <-outCh + // Release team create lock — tasks already visible in DB, other goroutines can list. + ptd.ReleaseTeamLock() + // Post-turn: dispatch pending team tasks created during this turn. if postTurn != nil { for teamID, taskIDs := range ptd.Drain() { diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 0c14fdde4..49b732919 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -154,6 +154,7 @@ func wireExtras( DynamicLoader: dynamicLoader, AgentLinkStore: stores.AgentLinks, TeamStore: stores.Teams, + DataDir: workspace, SecureCLIStore: stores.SecureCLI, BuiltinToolStore: stores.BuiltinTools, MCPStore: stores.MCP, @@ -395,9 +396,15 @@ func wireExtras( postTurn = teamMgr toolsReg.Register(tools.NewTeamTasksTool(teamMgr)) toolsReg.Register(tools.NewTeamMessageTool(teamMgr)) - toolsReg.Register(tools.NewWorkspaceWriteTool(teamMgr, workspace)) - toolsReg.Register(tools.NewWorkspaceReadTool(teamMgr, workspace)) - slog.Info("team + workspace tools registered", "workspace", workspace) + // Wire workspace interceptor into write_file so team workspace validation + // and event broadcasting happen transparently via existing file tools. + wsInterceptor := tools.NewWorkspaceInterceptor(teamMgr) + if writeTool, ok := toolsReg.Get("write_file"); ok { + if wia, ok := writeTool.(tools.WorkspaceInterceptorAware); ok { + wia.SetWorkspaceInterceptor(wsInterceptor) + } + } + slog.Info("team tools registered", "workspace", workspace) // Team cache invalidation via pub/sub msgBus.Subscribe(bus.TopicCacheTeam, func(event bus.Event) { diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 2883a176c..4cf71ceec 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -29,7 +29,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore // Phase 2: Pairing (store created externally, shared with channel manager). // OnApprove callback is set later by the caller after channel manager is created. - pairingMethods := methods.NewPairingMethods(pairingStore, msgBus) + pairingMethods := methods.NewPairingMethods(pairingStore, msgBus, server.RateLimiter()) pairingMethods.Register(router) // Phase 2: Usage (queries SessionStore for real token data) diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 2ba68d0a6..370a316cb 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -87,7 +87,7 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { } if cfg.Providers.DashScope.APIKey != "" { - registry.Register(providers.NewDashScopeProvider(cfg.Providers.DashScope.APIKey, cfg.Providers.DashScope.APIBase, "qwen3-max")) + registry.Register(providers.NewDashScopeProvider("dashscope", cfg.Providers.DashScope.APIKey, cfg.Providers.DashScope.APIBase, "qwen3-max")) slog.Info("registered provider", "name", "dashscope") } @@ -295,7 +295,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi registry.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) case store.ProviderDashScope: - registry.Register(providers.NewDashScopeProvider(p.APIKey, p.APIBase, "")) + registry.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) case store.ProviderBailian: base := p.APIBase if base == "" { diff --git a/docker-compose.yml b/docker-compose.yml index 85342035c..04595a890 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,9 @@ services: context: . dockerfile: Dockerfile args: - ENABLE_OTEL: "false" - ENABLE_PYTHON: "true" + ENABLE_OTEL: "${ENABLE_OTEL:-false}" + ENABLE_PYTHON: "${ENABLE_PYTHON:-true}" + ENABLE_FULL_SKILLS: "${ENABLE_FULL_SKILLS:-false}" ports: - "${GOCLAW_PORT:-18790}:18790" env_file: diff --git a/docs/00-architecture-overview.md b/docs/00-architecture-overview.md index 84d72ca7f..2cfb1eda1 100644 --- a/docs/00-architecture-overview.md +++ b/docs/00-architecture-overview.md @@ -17,6 +17,7 @@ flowchart TD ZL[Zalo OA] ZLP[Zalo Personal] WA[WhatsApp] + SL[Slack] end subgraph Gateway["Gateway Server"] @@ -53,8 +54,6 @@ flowchart TD SUB[Subagent] DEL[Delegation] TEAM_T[Teams] - EVAL[Evaluate Loop] - HO[Handoff] TTS_T[TTS] BROW[Browser] SK[Skills] @@ -78,13 +77,12 @@ flowchart TD TRACE_S[TracingStore] MCP_S[MCPServerStore] CT_S[CustomToolStore] - AL_S[AgentLinkStore] TM_S[TeamStore] end WS --> WSS HTTP --> HTTPS - TG & DC & FS & ZL & ZLP & WA --> CM + TG & DC & FS & ZL & ZLP & WA & SL --> CM WSS --> MR HTTPS --> MR @@ -108,33 +106,41 @@ flowchart TD | Module | Description | |--------|-------------| | `internal/gateway/` | WebSocket + HTTP server, client handling, method router | -| `internal/gateway/methods/` | RPC method handlers: chat, agents, agent_links, teams, delegations, sessions, config, skills, cron, pairing, exec approval, usage, send | +| `internal/gateway/methods/` | RPC method handlers: chat, agents, teams, delegations, sessions, config, skills, cron, pairing, exec approval, usage, send | | `internal/agent/` | Agent loop (think, act, observe), router, resolver, system prompt builder, sanitization, pruning, tracing, memory flush, DELEGATION.md + TEAM.md injection | -| `internal/providers/` | LLM providers: Anthropic (native HTTP + SSE), OpenAI-compatible (HTTP + SSE, 12+ providers), DashScope (Qwen), ACP (JSON-RPC 2.0 subprocess), extended thinking support, retry logic | +| `internal/providers/` | LLM providers: Anthropic (native HTTP + SSE), OpenAI-compatible (HTTP + SSE, 12+ providers), DashScope (Qwen), ACP (JSON-RPC 2.0 subprocess), Claude CLI, Codex, extended thinking support, retry logic | | `internal/providers/acp/` | ACP protocol implementation: ProcessPool (subprocess lifecycle), ToolBridge (fs/terminal), session management | -| `internal/tools/` | Tool registry, filesystem ops, exec/shell, policy engine, subagent, delegation manager, team tools, evaluate loop, handoff, context file + memory interceptors, credential scrubbing, rate limiting, PathDenyable | +| `internal/tools/` | Tool registry, filesystem ops, exec/shell, policy engine, subagent, delegation manager, team tools, context file + memory interceptors, credential scrubbing, rate limiting, PathDenyable | | `internal/tools/dynamic_loader.go` | Custom tool loader: LoadGlobal (startup), LoadForAgent (per-agent clone), ReloadGlobal (cache invalidation) | | `internal/tools/dynamic_tool.go` | Custom tool executor: command template rendering, shell escaping, encrypted env vars | | `internal/hooks/` | Hook engine: quality gates, command evaluator, agent evaluator, recursion prevention (`WithSkipHooks`) | -| `internal/store/` | Store interfaces: SessionStore, AgentStore, ProviderStore, SkillStore, MemoryStore, CronStore, PairingStore, TracingStore, MCPServerStore, AgentLinkStore, TeamStore, ChannelInstanceStore, ConfigSecretsStore | +| `internal/store/` | Store interfaces: SessionStore, AgentStore, ProviderStore, SkillStore, MemoryStore, CronStore, PairingStore, TracingStore, MCPServerStore, TeamStore, ChannelInstanceStore, ConfigSecretsStore | | `internal/store/pg/` | PostgreSQL implementations (`database/sql` + `pgx/v5`) | | `internal/bootstrap/` | System prompt files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, BOOTSTRAP.md) + seeding + truncation | | `internal/config/` | Config loading (JSON5) + env var overlay | | `internal/skills/` | SKILL.md loader (5-tier hierarchy) + BM25 search + hot-reload via fsnotify | -| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo OA, Zalo Personal, Discord, WhatsApp | +| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo OA, Zalo Personal, Discord, WhatsApp, Slack | | `internal/mcp/` | MCP server bridge (stdio, SSE, streamable-HTTP transports) | | `internal/scheduler/` | Lane-based concurrency control (main, subagent, cron, delegate lanes) with per-session serialization | | `internal/memory/` | Memory system (pgvector hybrid search) | | `internal/permissions/` | RBAC policy engine (admin, operator, viewer roles) | | `internal/store/pg/pairing.go` | DM/device pairing service (8-character codes, database-backed) | -| `internal/sessions/` | Session manager | -| `internal/bus/` | Event pub/sub (Message Bus) | | `internal/sandbox/` | Docker-based code execution sandbox | | `internal/tts/` | Text-to-Speech providers: OpenAI, ElevenLabs, Edge, MiniMax | | `internal/http/` | HTTP API handlers: /v1/chat/completions, /v1/agents, /v1/skills, /v1/traces, /v1/mcp, /v1/delegations, summoner | | `internal/crypto/` | AES-256-GCM encryption for API keys | | `internal/tracing/` | LLM call tracing (traces + spans), in-memory buffer with periodic store flush | | `internal/tracing/otelexport/` | Optional OpenTelemetry OTLP exporter (opt-in via build tags; adds gRPC + protobuf) | +| `internal/cache/` | Caching layer for agent state and provider responses | +| `internal/bus/` | Event pub/sub message bus for inter-component communication | +| `internal/hooks/` | Hook system for extensibility: command evaluator, agent evaluator, quality gates | +| `internal/knowledgegraph/` | Knowledge graph storage and traversal | +| `internal/mcp/` | Model Context Protocol bridge/server (stdio, SSE, streamable-HTTP) | +| `internal/media/` | Media handling utilities | +| `internal/oauth/` | OAuth authentication integration | +| `internal/sessions/` | Session management and lifecycle | +| `internal/tasks/` | Task management system | +| `internal/upgrade/` | Database schema version tracking and migrations | --- @@ -248,10 +254,10 @@ flowchart TD W5["5. Virtual FS Interceptors
Wire interceptors on read_file + write_file + memory tools"] --> W6 W6["6. Memory Store Wiring
Wire PGMemoryStore on memory_search + memory_get tools"] --> W7 W7["7. Cache Invalidation Subscribers
Subscribe to MessageBus events"] --> W8 - W8["8. Delegation Tools
DelegateManager + delegate_search + agent links"] --> W9 + W8["8. Delegation Tools
DelegateManager + agent links"] --> W9 W9["9. Team Tools
team_tasks + team_message + team auto-linking"] --> W10 W10["10. Hook Engine
Quality gates with command + agent evaluators"] --> W11 - W11["11. Evaluate Loop + Handoff
evaluate_loop tool + handoff tool"] + W11["11. Team Mailbox
team_message tool for peer communication"] ``` ### Cache Invalidation Events @@ -306,10 +312,10 @@ flowchart TD | Lane | Concurrency | Env Override | Purpose | |------|:-----------:|-------------|---------| -| `main` | 2 | `GOCLAW_LANE_MAIN` | Primary user chat sessions | -| `subagent` | 4 | `GOCLAW_LANE_SUBAGENT` | Spawned subagents | +| `main` | 30 | `GOCLAW_LANE_MAIN` | Primary user chat sessions | +| `subagent` | 50 | `GOCLAW_LANE_SUBAGENT` | Spawned subagents | | `delegate` | 100 | `GOCLAW_LANE_DELEGATE` | Agent delegation executions | -| `cron` | 1 | `GOCLAW_LANE_CRON` | Scheduled cron jobs | +| `cron` | 30 | `GOCLAW_LANE_CRON` | Scheduled cron jobs | ### Session Queue Concurrency @@ -397,7 +403,7 @@ flowchart TD | `cmd/gateway.go` | Gateway startup orchestrator (`runGateway()`) | | `cmd/gateway_managed.go` | Database wiring (`wireManagedExtras()`, `wireManagedHTTP()`) | | `cmd/gateway_callbacks.go` | Shared callbacks (user seeding, context file loading) | -| `cmd/gateway_consumer.go` | Inbound message consumer (subagent, delegate, teammate, handoff routing) | +| `cmd/gateway_consumer.go` | Inbound message consumer (subagent, delegate, teammate routing) | | `cmd/gateway_providers.go` | Provider registration (config-based + DB-based) | | `cmd/gateway_methods.go` | RPC method registration | | `internal/config/config.go` | Config struct definitions | @@ -412,7 +418,7 @@ flowchart TD | `internal/hooks/command_evaluator.go` | Shell command evaluator (exit 0 = pass) | | `internal/hooks/agent_evaluator.go` | Agent delegation evaluator (APPROVED/REJECTED) | | `internal/hooks/context.go` | `WithSkipHooks` / `SkipHooksFromContext` (recursion prevention) | -| `internal/store/stores.go` | `Stores` container struct (all 14 store interfaces) | +| `internal/store/stores.go` | `Stores` container struct (all 22+ store interfaces) | | `internal/store/types.go` | `StoreConfig`, `BaseModel` | --- diff --git a/docs/01-agent-loop.md b/docs/01-agent-loop.md index 9973f46c9..58d0b2898 100644 --- a/docs/01-agent-loop.md +++ b/docs/01-agent-loop.md @@ -111,9 +111,10 @@ flowchart TD - Filter the available tools through the PolicyEngine (RBAC). - Call the LLM. Streaming calls emit `chunk` events in real time; non-streaming calls return a single response. - Record an LLM span for tracing with token counts and timing. +- **Mid-loop compaction**: if prompt tokens exceed 75% of context window (or `MaxHistoryShare` if configured), summarize ~70% of in-memory messages, keeping the last ~30%. This happens during active iterations to prevent context overflow in long-running tasks. - If the response contains no tool calls, exit the loop. - If tool calls are present, proceed to Phase 5 and then loop back. -- Maximum 20 iterations before the loop forcibly exits. +- Maximum iterations before loop forcibly exits (default 20, set via `maxIterations` in agent config or `req.MaxIterations` per-request). ### Phase 5: Tool Execution @@ -149,32 +150,36 @@ When the context is cancelled (via `/stop` or `/stopall`), the loop exits immedi ## 2. System Prompt -The system prompt is assembled dynamically from 15+ sections. Two modes control the amount of content included: +The system prompt is assembled dynamically from 19 sections. Two modes control the amount of content included: - **PromptFull**: used for main agent runs. Includes all sections. -- **PromptMinimal**: used for sub-agents and cron jobs. Stripped-down version with only essential context. - -### Sections - -1. **Identity** -- agent persona loaded from bootstrap files (IDENTITY.md, SOUL.md). -2. **First-run bootstrap** -- instructions shown only on the very first interaction. -3. **Tooling** -- descriptions and usage guidelines for available tools. -4. **Safety** -- defensive preamble for handling external content, wrapped in XML tags. -5. **Skills (inline)** -- skill content injected directly when the skill set is small. -6. **Skills (search mode)** -- BM25 skill search tool when the skill set is large. -7. **Memory Recall** -- recalled memory snippets relevant to the current conversation. -8. **Workspace** -- working directory path and file structure context. -9. **Sandbox** -- Docker sandbox instructions when sandbox mode is enabled. -10. **User Identity** -- the current user's display name and identifier. -11. **Time** -- current date and time for temporal awareness. -12. **Messaging** -- channel-specific formatting instructions (Telegram, Feishu, etc.). -13. **Extra context** -- additional prompt text wrapped in `` XML tags. -14. **Project Context** -- context files loaded from the database or filesystem, wrapped in `` XML tags with a defensive preamble. -15. **Silent Replies** -- instructions for the NO_REPLY convention. -16. **Sub-Agent Spawning** -- rules for launching child agents. -18. **Delegation** -- auto-generated `DELEGATION.md` listing available delegation targets (inline if ≤15, search instruction if >15). -19. **Team** -- `TEAM.md` injected for team leads only (team name, role, teammate list). -20. **Runtime** -- runtime metadata (agent ID, session key, provider info). +- **PromptMinimal**: used for sub-agents and cron jobs. Reduced sections (only AGENTS.md and TOOLS.md from bootstrap files). + +### Sections (In Build Order) + +1. **Identity** -- channel-aware context with platform type (Telegram, Zalo, etc.) and chat type (direct/group). +2. **First-run bootstrap** -- `[MANDATORY]` notice injected if BOOTSTRAP.md is present, forcing immediate execution. +3. **Persona** -- SOUL.md and IDENTITY.md injected early in the "primacy zone" to prevent drift in long conversations. +4. **Tooling** -- core tool descriptions, filtered by policy and sandbox status. +5. **Credentialed CLI** -- optional secure CLI context for credentialed exec tool access. +6. **Safety** -- defensive preamble for handling external content, identity anchoring for predefined agents. +7. **Self-Evolution** -- rules for predefined agents to update SOUL.md (style/tone) from user feedback. +8. **Skills (inline)** -- skill content injected directly when the skill set is small (≤15 skills). +9. **Skills (search mode)** -- use `skill_search` tool when the skill set is large. +10. **MCP Tools (inline)** -- external integration tools with real descriptions. +11. **MCP Tools (search mode)** -- use `mcp_tool_search` when many MCP tools are available. +12. **Workspace** -- working directory path, file structure, sandbox container workdir. +13. **Team Workspace** -- absolute path to shared team workspace (for team agents). +14. **Sandbox** -- Docker container instructions, available commands, policy notes. +15. **User Identity** -- owner IDs for permission checks (full mode only). +16. **Time** -- current UTC date/time for temporal awareness. +17. **Channel Formatting** -- platform-specific output hints (e.g., Zalo → plain text). +18. **Extra Context** -- additional context wrapped in `` tags (subagent context, etc.). +19. **Project Context** -- bootstrap context files (remaining after persona extraction), wrapped in defensive preamble. +20. **Sub-Agent Spawning** -- rules for launching child agents (skipped for team agents with TEAM.md). +21. **Runtime** -- agent ID, session key, provider info, model pricing. +22. **Persona Reminder** -- recency reinforcement to combat "lost in the middle" in long conversations. +23. **Memory Reminders** -- prompts to run memory_search and knowledge_graph_search before answering. --- @@ -312,15 +317,31 @@ The following messages are never pruned: ## 7. Auto-Summarize and Compaction -When the conversation grows too long, the auto-summarization system compresses older history into a summary while preserving recent context. +The system uses a two-stage compaction strategy: **mid-loop** (during active iterations) and **post-run** (after completion). + +### Mid-Loop Compaction (During Iteration) + +When in-memory messages exceed 75% of context window during LLM iterations, the agent immediately summarizes the first ~70% of messages in place, keeping the last ~30%. This prevents context overflow in long-running tasks without waiting for post-run summarization. + +``` +Threshold: prompt_tokens >= contextWindow * 0.75 (configurable via MaxHistoryShare) +Trigger: Once per run, inside the iteration loop (between LLM calls) +Output: In-memory messages replaced with [summary] + [recent 4 messages] +``` + +### Post-Run Compaction (After Completion) + +When the session history exceeds thresholds **after** a run completes, the session is compacted in the background. ```mermaid flowchart TD CHECK{"> 50 messages OR
> 75% context window?"} CHECK -->|No| SKIP[Skip compaction] - CHECK -->|Yes| FLUSH + CHECK -->|Yes| LOCK["Per-session non-blocking lock
(skip if another run already compacting)"] + LOCK -->|Lock acquired| FLUSH + LOCK -->|Already locked| SKIP - FLUSH["Step 1: Memory Flush (synchronous)
LLM turn with write_file tool
Agent writes durable memories before truncation
Max 5 iterations, 90s timeout"] + FLUSH["Step 1: Memory Flush (synchronous)
Embedded agent turn with write_file tool
Agent stores durable memories before truncation
Uses PromptMinimal mode
Max 5 iterations, 90s timeout"] FLUSH --> SUMMARIZE SUMMARIZE["Step 2: Summarize (background goroutine)
Keep last 4 messages
LLM summarizes older messages
temp=0.3, max_tokens=1024, timeout 120s"] @@ -333,22 +354,42 @@ flowchart TD On the next request, the saved summary is injected at the beginning of the message list as two messages: -1. `{role: "user", content: "[Previous conversation summary]\n{summary}"}` +1. `{role: "user", content: "[Summary of earlier conversation]\n{summary}"}` 2. `{role: "assistant", content: "I understand the context..."}` -This gives the LLM continuity without replaying the full history. +This gives the LLM continuity without replaying the full history. Protected zone: the last 3 assistant messages are never pruned. --- ## 8. Memory Flush -Memory flush runs synchronously before compaction to give the agent an opportunity to persist important information. +Memory flush runs **synchronously before post-run compaction** to give the agent an opportunity to persist important information before session history is truncated. + +### Trigger Conditions + +- **Primary**: compaction is about to run (message count or token ratio exceeded). +- **Token threshold**: only runs when session tokens are significant enough to warrant capture. +- **Deduplication**: runs at most once per compaction cycle, tracked by comparing compaction counter. + +### Mechanism + +An embedded agent turn with special configuration: + +- **System prompt mode**: `PromptMinimal` (stripped-down context). +- **Message window**: latest 10 messages only (not the full history). +- **Available tools**: `write_file` and `read_file` for memory file operations. +- **Default prompt**: "Pre-compaction memory flush. Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed). If nothing to store, reply with NO_REPLY." +- **Output handling**: recognizes `NO_REPLY` convention (silent completion). + +### Timing -- **Trigger**: token estimate >= contextWindow - 20,000 - 4,000. -- **Deduplication**: runs at most once per compaction cycle, tracked by the compaction counter. -- **Mechanism**: an embedded agent turn using `PromptMinimal` mode with a flush prompt and the 10 most recent messages. The default prompt is: "Store durable memories now, if nothing to store reply NO_REPLY." -- **Available tools**: `write_file` and `read_file`, so the agent can write and read memory files. -- **Timing**: fully synchronous -- blocks the summarization step until the flush completes. +- **Synchronous blocking**: blocks the entire post-run path until flush LLM call completes. +- **Timeout**: 90 seconds for the entire flush turn (5 max iterations). +- **Configurable**: can be disabled or customized via `compaction.memory_flush` config section. + +### Results + +The agent can write findings to `memory/YYYY-MM-DD.md` files. These persist across session compaction and are available to future sessions via `memory_search` and `memory_get` tools. --- @@ -399,15 +440,47 @@ flowchart TD ### Resolved Properties - **Provider**: looked up by name from the provider registry. Falls back to the first registered provider if not found. -- **Bootstrap files**: loaded from the `agent_context_files` table (agent-level files like IDENTITY.md, SOUL.md). -- **Agent type**: `open` (per-user context with 7 template files) or `predefined` (agent-level context plus USER.md per user). +- **Bootstrap files**: loaded from the workspace directory via `bootstrap.LoadWorkspaceFiles()`. Standard files: AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, BOOTSTRAP.md. Additional files (MEMORY.md, USER_PREDEFINED.md, DELEGATION.md, TEAM.md, AVAILABILITY.md) loaded separately as needed. Per-user files (USER.md) created on first chat via `EnsureUserFilesFunc`. +- **Agent type**: `open` (per-user context, seeded from template files) or `predefined` (agent-level context plus per-user USER.md overlay). - **Per-user seeding**: `EnsureUserFilesFunc` seeds template files on first chat, idempotent (skips files that already exist). Uses PostgreSQL's `xmax` trick in `GetOrCreateUserProfile` to distinguish INSERT from ON CONFLICT UPDATE, triggering seeding only for genuinely new users. -- **Dynamic context loading**: `ContextFileLoaderFunc` resolves context files based on agent type -- per-user files for open agents, agent-level files for predefined agents. +- **Dynamic context loading**: `ContextFileLoaderFunc` resolves context files based on agent type and request context. Returns a `[]bootstrap.ContextFile` list with truncated content for system prompt injection. For open agents: loads per-user files from workspace. For predefined agents: loads agent-level files plus per-user USER.md. - **Custom tools**: `DynamicLoader.LoadForAgent()` clones the global tool registry and adds per-agent custom tools, ensuring each agent gets its own isolated set of dynamic tools. +- **Team context**: auto-resolved for agents that belong to a team. Lead agents get the team workspace as default workspace; non-lead members keep their own workspace with team workspace accessible via absolute path tool context. + +--- + +## 11. Team Workspace Handling + +Agents that belong to a team have access to shared team workspaces for collaboration. + +### Workspace Resolution + +**For dispatched tasks** (via `req.TeamWorkspace`): +- The team workspace becomes the **default workspace** for relative path operations +- All file tools (read_file, write_file, list_files) use team workspace by default +- Agent workspace is still accessible via `WithToolTeamWorkspace()` context for absolute-path access + +**For direct chat** (auto-resolved via team membership): +- Lead agents get team workspace as their default workspace (primary job is team coordination) +- Non-lead member agents keep their own workspace as default +- Team workspace is accessible via `WithToolTeamWorkspace()` context + +### Path Scoping + +- **Shared workspace mode** (team.settings.shared_workspace): all agents in team share single workspace +- **Isolated workspace mode** (default): each agent gets a workspace scoped by `(teamID, chatID)` or `(teamID, userID)` + +### Context Variables + +During runs with team context: +- `WithToolTeamWorkspace(ctx, wsDir)` — absolute path to shared team workspace +- `WithToolWorkspace(ctx, effectiveWorkspace)` — effective default workspace for file operations +- `WithToolTeamID(ctx, teamID)` — team UUID string for team-scoped tool operations +- `WithToolTaskID(ctx, taskID)` — team task ID when executing dispatched team tasks --- -## 11. Event System +## 12. Event System The Loop publishes events via an `onEvent` callback. The WebSocket gateway forwards these as `EventFrame` messages to connected clients for real-time progress tracking. @@ -415,13 +488,16 @@ The Loop publishes events via an `onEvent` callback. The WebSocket gateway forwa | Event | When | Payload | |-------|------|---------| -| `run.started` | Run begins | -- | +| `run.started` | Run begins | `{"message": "..."}` | +| `activity` | Phase transitions | `{"phase": "thinking"|"tool_exec"|"compacting", "iteration": N}` | | `chunk` | Streaming: each text fragment from the LLM | `{"content": "..."}` | -| `tool.call` | Tool execution begins | `{"name": "...", "id": "..."}` | -| `tool.result` | Tool execution completes | `{"name": "...", "id": "...", "is_error": bool}` | -| `run.completed` | Run finishes successfully | -- | +| `thinking` | Streaming: thinking tokens (extended thinking models) | `{"content": "..."}` | +| `tool.call` | Tool execution begins | `{"name": "...", "id": "...", "arguments": {...}}` | +| `tool.result` | Tool execution completes | `{"name": "...", "id": "...", "is_error": bool, "result": "..."}` | +| `block.reply` | Intermediate assistant content during tool iterations | `{"content": "..."}` | +| `run.retrying` | LLM provider retry after failure | `{"attempt": N, "maxAttempts": M, "error": "..."}` | +| `run.completed` | Run finishes successfully | `{"content": "...", "usage": {...}}` | | `run.failed` | Run finishes with an error | `{"error": "..."}` | -| `handoff` | Conversation transferred to another agent | `{"from": "...", "to": "...", "reason": "..."}` | ### Event Flow @@ -449,7 +525,7 @@ sequenceDiagram --- -## 12. Tracing +## 13. Tracing Every agent run produces a trace with a hierarchy of spans for debugging, analysis, and cost tracking. @@ -484,16 +560,21 @@ Enabled via the `GOCLAW_TRACE_VERBOSE=1` environment variable. --- -## 13. File Reference +## 14. File Reference | File | Responsibility | |------|---------------| -| `internal/agent/loop.go` | Core Loop struct, RunRequest/RunResult, LLM iteration loop, tool execution, event emission | -| `internal/agent/loop_history.go` | History pipeline: limitHistoryTurns, sanitizeHistory, summary injection | +| `internal/agent/loop_run.go` | Run() entry point: trace creation, span management, event emission wrapper | +| `internal/agent/loop.go` | runLoop() core loop: LLM iteration, tool execution, message buffering, event emission | +| `internal/agent/loop_history.go` | History pipeline: limitHistoryTurns, pruneContextMessages, sanitizeHistory, summary injection | | `internal/agent/pruning.go` | Context pruning: 2-pass soft trim and hard clear algorithm | -| `internal/agent/systemprompt.go` | System prompt assembly (15+ sections), PromptFull and PromptMinimal modes | +| `internal/agent/loop_compact.go` | Mid-loop compaction: in-memory message summarization during iterations | +| `internal/agent/systemprompt.go` | System prompt assembly (19+ sections), PromptFull and PromptMinimal modes | +| `internal/agent/systemprompt_sections.go` | Individual section builders (tooling, workspace, sandbox, skills, MCP, etc.) | | `internal/agent/resolver.go` | ManagedResolver: lazy Loop creation from PostgreSQL, provider resolution, bootstrap loading | | `internal/agent/loop_tracing.go` | Trace and span creation, verbose mode input capture, span finalization | | `internal/agent/input_guard.go` | Input Guard: 6 regex patterns, 4 action modes, security logging | | `internal/agent/sanitize.go` | 7-step output sanitization pipeline | | `internal/agent/memoryflush.go` | Pre-compaction memory flush: embedded agent turn with write_file tool | +| `internal/agent/toolloop.go` | Tool execution and loop detection (no-progress warnings) | +| `internal/bootstrap/files.go` | Bootstrap file loading and context file preparation | diff --git a/docs/02-providers.md b/docs/02-providers.md index 3b0deb184..fefe0cd9c 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -1,6 +1,6 @@ # 02 - LLM Providers -GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Two concrete implementations exist: an Anthropic provider using native `net/http` with SSE streaming, and a generic OpenAI-compatible provider that covers 10+ API endpoints. +GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Six concrete implementations exist: Anthropic (native HTTP+SSE), OpenAI-compatible (covering 10+ API endpoints), Claude CLI (local binary), Codex (OAuth-based), ACP (subagent orchestration), and DashScope (Alibaba Qwen with thinking). --- @@ -14,40 +14,67 @@ flowchart TD PI --> ANTH["Anthropic Provider
native net/http + SSE"] PI --> OAI["OpenAI-Compatible Provider
generic HTTP client"] + PI --> CLAUDE["Claude CLI Provider
stdio subprocess"] + PI --> CODEX["Codex Provider
OAuth-based Responses API"] + PI --> ACP["ACP Provider
JSON-RPC 2.0 subagents"] + PI --> DASH["DashScope Provider
OpenAI-compat wrapper"] - ANTH --> CLAUDE["Claude API
api.anthropic.com/v1"] + ANTH --> ANTHROPIC["Claude API
api.anthropic.com/v1"] OAI --> OPENAI["OpenAI API"] OAI --> OR["OpenRouter API"] OAI --> GROQ["Groq API"] OAI --> DS["DeepSeek API"] OAI --> GEM["Gemini API"] - OAI --> OTHER["Mistral / xAI / MiniMax
Cohere / Perplexity"] + OAI --> OTHER["Mistral / xAI / MiniMax
Cohere / Perplexity / Ollama"] + CLAUDE --> CLI["claude CLI binary
stdio + MCP bridge"] + CODEX --> CODEX_API["ChatGPT Responses API
chatgpt.com/backend-api"] + ACP --> AGENTS["Claude Code / Codex
Gemini CLI agents"] + DASH --> QWEN["Alibaba DashScope
Qwen3 models"] ``` -The Anthropic provider uses `x-api-key` header authentication and the `anthropic-version: 2023-06-01` header. The OpenAI-compatible provider uses `Authorization: Bearer` tokens and targets each provider's `/chat/completions` endpoint. Both providers set an HTTP client timeout of 120 seconds. +Authentication and timeouts vary by provider type: +- **Anthropic**: `x-api-key` header + `anthropic-version: 2023-06-01` +- **OpenAI-compatible**: `Authorization: Bearer` token +- **Claude CLI**: stdio subprocess (no auth; uses local CLI session) +- **Codex**: OAuth access token (auto-refreshed via TokenSource) +- **ACP**: JSON-RPC 2.0 over subprocess stdio +- **DashScope**: `Authorization: Bearer` token (inherits from OpenAI-compatible) + +All HTTP-based providers (Anthropic, OpenAI-compatible, Codex) use 300-second timeout. --- ## 2. Supported Providers -| Provider | Type | API Base / Binary | Default Model | +### Six Core Provider Types + +| Provider | Type | Configuration | Default Model | |----------|------|----------|---------------| -| anthropic | Native HTTP + SSE | `https://api.anthropic.com/v1` | `claude-sonnet-4-5-20250929` | -| openai | OpenAI-compatible | `https://api.openai.com/v1` | `gpt-4o` | -| openrouter | OpenAI-compatible | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-4-5-20250929` | -| groq | OpenAI-compatible | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | -| deepseek | OpenAI-compatible | `https://api.deepseek.com/v1` | `deepseek-chat` | -| gemini | OpenAI-compatible | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | -| mistral | OpenAI-compatible | `https://api.mistral.ai/v1` | `mistral-large-latest` | -| xai | OpenAI-compatible | `https://api.x.ai/v1` | `grok-3-mini` | -| minimax | OpenAI-compatible | `https://api.minimax.chat/v1` | `MiniMax-M2.5` | -| cohere | OpenAI-compatible | `https://api.cohere.com/v2` | `command-a` | -| perplexity | OpenAI-compatible | `https://api.perplexity.ai` | `sonar-pro` | -| dashscope | OpenAI-compatible | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3-max` | -| bailian | OpenAI-compatible | `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.5-plus` | -| zai | OpenAI-compatible | `https://api.z.ai/api/paas/v4` | `glm-5` | -| zai_coding | OpenAI-compatible | `https://api.z.ai/api/coding/paas/v4` | `glm-5` | -| acp | ACP (JSON-RPC 2.0 stdio) | `claude`, `codex`, `gemini` (binary name) | `claude` | +| **anthropic** | Native HTTP + SSE | API key required | `claude-sonnet-4-5-20250929` | +| **claude_cli** | stdio subprocess + MCP | Binary path (default: `claude`) | `sonnet` | +| **codex** | OAuth Responses API | OAuth token source | `gpt-5.3-codex` | +| **acp** | JSON-RPC 2.0 subagents | Binary + workspace dir | `claude` | +| **dashscope** | OpenAI-compat wrapper | API key + custom models | `qwen3-max` | +| **openai** (+ 10+ variants) | OpenAI-compatible | API key + endpoint URL | Model-specific | + +### OpenAI-Compatible Providers + +| Provider | API Base | Default Model | Notes | +|----------|----------|---------------|-------| +| openai | `https://api.openai.com/v1` | `gpt-4o` | | +| openrouter | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-4-5-20250929` | Model must contain `/` | +| groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | | +| deepseek | `https://api.deepseek.com/v1` | `deepseek-chat` | | +| gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Skips empty content fields | +| mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` | | +| xai | `https://api.x.ai/v1` | `grok-3-mini` | | +| minimax | `https://api.minimax.io/v1` | `MiniMax-M2.5` | Uses custom chat path | +| cohere | `https://api.cohere.ai/compatibility/v1` | `command-a` | | +| perplexity | `https://api.perplexity.ai` | `sonar-pro` | | +| ollama | `http://localhost:11434/v1` | `llama3.3` | Local/configurable | +| bailian | `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.5-plus` | Alibaba Coding API | +| zai | `https://api.z.ai/api/paas/v4` | `glm-5` | | +| zai-coding | `https://api.z.ai/api/coding/paas/v4` | `glm-5` | | --- @@ -452,29 +479,196 @@ Emits `StreamChunk` for each text delta via callback. Supports context cancellat --- -## 11. Agent Evaluators (Hook System) +## 11. Claude CLI Provider + +The Claude CLI provider enables GoClaw to delegate requests to a local `claude` CLI binary. The CLI manages session history, context files, and tool execution independently; GoClaw only passes messages and streams responses back. + +### Architecture Overview + +```mermaid +flowchart TD + AL["Agent Loop"] -->|Chat / ChatStream| CLI["ClaudeCLIProvider"] + CLI --> POOL["SessionPool"] + POOL -->|spawn/reuse| PROC["Subprocess
claude --server=stdio"] + PROC -->|manages| SESS["Session
(session ID, history)"] + + SESS -->|fs/readTextFile| TOOLS["CLI Tool Execution"] + SESS -->|fs/writeTextFile| TOOLS + SESS -->|exec/run| TOOLS + SESS -->|web/fetch| TOOLS + + TOOLS -->|via MCP| MCP["MCP Servers
(if configured)"] +``` + +### Configuration + +ClaudeCLIProvider can be configured in `config.json`: + +```json5 +{ + "providers": { + "claude_cli": { + "cli_path": "claude", // binary path or name + "default_model": "sonnet", // opus, sonnet, haiku + "base_work_dir": "/tmp/agents", // workspace directory + "perm_mode": "bypassPermissions", // permission mode + "disable_hooks": false, // disable security hooks if true + "deny_patterns": ["^/etc/", "^\\.env"] + } + } +} +``` + +Or via database `llm_providers` table with `provider_type = "claude_cli"`. + +### Session Management + +Each conversation gets a persistent session tied to `session_key` option. Sessions survive across multiple requests and maintain: +- Conversation history +- Workspace directory (for file operations) +- MCP server connections +- Tool execution state + +Idle sessions are automatically cleaned up after inactivity. + +### Tool Execution + +Claude CLI executes tools natively (filesystem, exec, web, memory). GoClaw forwards tool results back and lets the CLI loop continue. This differs from standard providers which return tool calls for the agent loop to execute. + +### Model Aliases + +Like the Anthropic provider, Claude CLI supports short aliases: +- `opus` → `claude-opus-4-6` +- `sonnet` → `claude-sonnet-4-6` +- `haiku` → `claude-haiku-4-5-20251001` + +### MCP Configuration + +Per-session MCP servers are configured via `MCPConfigData`. The CLI automatically loads and communicates with configured MCP servers for extended functionality. + +### Streaming + +- **Chat**: Returns complete response after CLI execution +- **ChatStream**: Streams text chunks as they are produced by the CLI + +### Thinking Support + +Claude CLI inherits thinking support from the underlying Claude model. Thinking blocks are passed through in streaming chunks if the model supports them. + +--- + +## 12. Codex Provider + +The Codex provider integrates with OpenAI's ChatGPT Responses API (OAuth-based), enabling access to gpt-5.3-codex model through the chatgpt.com backend. Unlike standard OpenAI endpoints, Codex uses OAuth token refresh and a custom response format with "phase" markers. + +### Configuration + +Codex requires an OAuth token source (handles auto-refresh): + +```go +tokenSource := &MyTokenSource{} // implements TokenSource interface +provider := NewCodexProvider("codex", tokenSource, "", "") +// or specify custom API base and model: +provider := NewCodexProvider("codex", tokenSource, + "https://chatgpt.com/backend-api", "gpt-5.3-codex") +``` + +### API Endpoint + +``` +POST https://chatgpt.com/backend-api/codex/responses +Authorization: Bearer {oauth_token} +``` + +The provider automatically handles token refresh via the TokenSource. + +### Response Format + +Codex returns structured responses with phase markers: + +```json +{ + "id": "...", + "model": "gpt-5.3-codex", + "choices": [{ + "message": { + "role": "assistant", + "content": "...", + "metadata": { + "phase": "commentary" // or "final_answer" + } + }, + "finish_reason": "stop" + }], + "usage": { ... } +} +``` + +### Phase Field + +The `phase` field indicates message purpose: +- `"commentary"` — intermediate reasoning +- `"final_answer"` — closeout response + +GoClaw persists this on assistant messages and passes it back in subsequent requests. Codex performance depends on this field being echoed correctly. + +### Streaming + +Codex supports SSE streaming similar to Anthropic: +- Each SSE event contains a partial response +- Phase marker included in final delta +- Tool calls streamed via `input_json_delta` equivalent + +### Extended Thinking + +Codex provider reports `SupportsThinking() = true`, allowing thinking_level to be injected. The provider maps thinking levels to reasoning_effort parameters as needed. + +### Token Usage + +Tracks prompt, completion, and total tokens. `CacheCreationTokens` and `CacheReadTokens` are supported for prompt caching if available. + +--- + +## 13. Agent Evaluators (Hook System) Agent evaluators in the quality gate / hook system (see [03-tools-system.md](./03-tools-system.md)) use the same provider resolution as normal agent runs. When a quality gate is configured with `"type": "agent"`, the hook engine delegates to the specified reviewer agent, which resolves its own provider through the standard provider registry. No separate provider configuration is needed for evaluator agents. --- -## File Reference +## 14. File Reference | File | Purpose | |------|---------| | `internal/providers/types.go` | Provider interface, ChatRequest, ChatResponse, Message, ToolCall, Usage types | -| `internal/providers/anthropic.go` | Anthropic provider implementation (native HTTP + SSE streaming) | -| `internal/providers/openai.go` | OpenAI-compatible provider implementation (generic HTTP) | -| `internal/providers/retry.go` | RetryDo[T] generic function, RetryConfig, IsRetryableError, backoff computation | -| `internal/providers/schema_cleaner.go` | CleanSchemaForProvider, CleanToolSchemas, recursive schema field removal | -| `internal/providers/dashscope.go` | DashScope provider: thinking budget, tools+streaming fallback | -| `internal/providers/acp_provider.go` | ACPProvider implementation: orchestrates ACP agents as subprocesses | +| `internal/providers/anthropic.go` | Anthropic provider: native HTTP + SSE, request/response marshaling | +| `internal/providers/anthropic_request.go` | Anthropic request builder: message formatting, tool schemas, system blocks | +| `internal/providers/anthropic_stream.go` | Anthropic SSE event parsing and response accumulation | +| `internal/providers/openai.go` | OpenAI-compatible provider: generic HTTP client for 10+ endpoints | +| `internal/providers/openai_types.go` | OpenAI request/response types and message formatting | +| `internal/providers/openai_gemini.go` | Gemini-specific compatibility: empty content handling, tool schema cleaning | +| `internal/providers/claude_cli.go` | ClaudeCLIProvider: orchestrates local claude CLI binary via stdio | +| `internal/providers/claude_cli_chat.go` | Chat/ChatStream implementation for CLI provider | +| `internal/providers/claude_cli_session.go` | Session management: per-session state, history, workspace | +| `internal/providers/claude_cli_mcp.go` | MCP configuration and server bridge for CLI provider | +| `internal/providers/claude_cli_auth.go` | Authentication and token handling for CLI | +| `internal/providers/claude_cli_parse.go` | Response parsing and message extraction from CLI output | +| `internal/providers/claude_cli_deny_patterns.go` | Path validation and deny pattern enforcement | +| `internal/providers/claude_cli_hooks.go` | Security hooks configuration for CLI tool execution | +| `internal/providers/claude_cli_types.go` | Internal types for CLI provider (session, config, options) | +| `internal/providers/codex.go` | CodexProvider: OAuth-based ChatGPT Responses API | +| `internal/providers/codex_build.go` | Codex request builder: message formatting, phase handling | +| `internal/providers/codex_types.go` | Codex request/response types and OAuth token management | +| `internal/providers/dashscope.go` | DashScope provider: OpenAI-compat wrapper with thinking budget, tools+streaming fallback | +| `internal/providers/acp_provider.go` | ACPProvider: orchestrates ACP-compatible agent subprocesses | | `internal/providers/acp/types.go` | ACP protocol types: InitializeRequest, SessionUpdate, ContentBlock, etc. | | `internal/providers/acp/process.go` | ProcessPool: subprocess lifecycle, idle TTL reaping, crash recovery | | `internal/providers/acp/jsonrpc.go` | JSON-RPC 2.0 request/response marshaling over stdio | | `internal/providers/acp/tool_bridge.go` | ToolBridge: handles fs and terminal requests, workspace sandboxing | | `internal/providers/acp/terminal.go` | Terminal lifecycle: create, output, exit, release, kill | | `internal/providers/acp/session.go` | Session state tracking per ACP agent | +| `internal/providers/retry.go` | RetryDo[T] generic function, RetryConfig, IsRetryableError, backoff computation | +| `internal/providers/schema_cleaner.go` | CleanSchemaForProvider, CleanToolSchemas, recursive schema field removal | +| `internal/providers/registry.go` | Provider registry: registration, lookup, lifecycle management | | `cmd/gateway_providers.go` | Provider registration from config and database during gateway startup | --- diff --git a/docs/03-tools-system.md b/docs/03-tools-system.md index f059e5fd1..1e911db25 100644 --- a/docs/03-tools-system.md +++ b/docs/03-tools-system.md @@ -53,7 +53,7 @@ Context keys ensure each tool call receives the correct per-call values without |------|-------------| | `read_file` | Read file contents with optional line range | | `write_file` | Write or create a file | -| `edit_file` | Apply targeted edits to a file | +| `edit` | Apply targeted edits to a file | | `list_files` | List directory contents | | `search` | Search file contents with regex | | `glob` | Find files matching a glob pattern | @@ -63,20 +63,20 @@ Context keys ensure each tool call receives the correct per-call values without | Tool | Description | |------|-------------| | `exec` | Execute a shell command | -| `process` | Manage running processes | +| `credentialed_exec` | Execute CLI with injected credentials (direct exec mode, no shell) | ### Web (group: `web`) | Tool | Description | |------|-------------| -| `web_search` | Search the web | +| `web_search` | Search the web (Brave, DuckDuckGo) | | `web_fetch` | Fetch and parse a URL | ### Memory (group: `memory`) | Tool | Description | |------|-------------| -| `memory_search` | Search memory documents | +| `memory_search` | Search memory documents (BM25 + vector) | | `memory_get` | Retrieve a specific memory document | ### Sessions (group: `sessions`) @@ -89,19 +89,19 @@ Context keys ensure each tool call receives the correct per-call values without | `spawn` | Spawn subagent or delegate to another agent | | `session_status` | Get current session status | -### UI (group: `ui`) +### Knowledge & Search (group: `knowledge`) | Tool | Description | |------|-------------| -| `browser` | Browser automation via Rod + CDP | -| `canvas` | Visual canvas operations | +| `knowledge_graph_search` | Search knowledge graph for entities and relationships | +| `skill_search` | Search available skills (BM25) | ### Automation (group: `automation`) | Tool | Description | |------|-------------| | `cron` | Manage scheduled tasks | -| `gateway` | Gateway administration commands | +| `datetime` | Get current date/time with timezone support | ### Messaging (group: `messaging`) @@ -115,9 +115,6 @@ Context keys ensure each tool call receives the correct per-call values without | Tool | Description | |------|-------------| | `delegate` | Delegate task to another agent (actions: delegate, cancel, list, history) | -| `delegate_search` | Hybrid FTS + semantic agent discovery for delegation targets | -| `evaluate_loop` | Generate-evaluate-revise cycle with two agents (max 5 rounds) | -| `handoff` | Transfer conversation to another agent (routing override) | ### Teams (group: `teams`) @@ -126,16 +123,64 @@ Context keys ensure each tool call receives the correct per-call values without | `team_tasks` | Task board: list, create, claim, complete, search | | `team_message` | Mailbox: send, broadcast, read unread messages | -### Other Tools +### Media Generation +#### Images | Tool | Description | |------|-------------| -| `skill_search` | Search available skills (BM25 + vector) | -| `image` | Generate images | -| `read_image` | Read/analyze an image file | -| `create_image` | Create an image from description | +| `create_image` | Generate images from text description (OpenAI, Gemini, MiniMax, DashScope) | + +#### Audio & Music +| Tool | Description | +|------|-------------| +| `create_audio` | Generate audio/music/sound effects (MiniMax music, ElevenLabs effects) | | `tts` | Text-to-speech synthesis (OpenAI, ElevenLabs, Edge, MiniMax) | -| `nodes` | Node graph operations | + +#### Video +| Tool | Description | +|------|-------------| +| `create_video` | Generate video from text/image (MiniMax) | + +### Media Reading + +#### Images +| Tool | Description | +|------|-------------| +| `read_image` | Analyze/describe images using vision AI (Gemini, Anthropic, OpenRouter, DashScope) | + +#### Audio & Speech +| Tool | Description | +|------|-------------| +| `read_audio` | Transcribe audio to text (Resolve transcription service) | + +#### Documents +| Tool | Description | +|------|-------------| +| `read_document` | Extract and analyze documents (PDF, images, etc) via Gemini or Resolve service | + +#### Video +| Tool | Description | +|------|-------------| +| `read_video` | Analyze/transcribe video content via Resolve service | + +### Skills & Content + +| Tool | Description | +|------|-------------| +| `use_skill` | Activate a skill (marker tool for observability) | +| `publish_skill` | Register a skill directory in the database | + +### AI & LLM + +| Tool | Description | +|------|-------------| +| `openai_compat_call` | Call OpenAI-compatible endpoints with custom request formats | + +### Workspace & Administration + +| Tool | Description | +|------|-------------| +| `workspace_dir` | Resolve workspace directory for team/user context | --- @@ -307,8 +352,8 @@ flowchart TD | Profile | Tools Included | |---------|---------------| | `full` | All registered tools (no restriction) | -| `coding` | `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `group:web`, `read_image`, `create_image`, `skill_search` | -| `messaging` | `group:messaging`, `group:web`, `group:sessions`, `read_image`, `skill_search` | +| `coding` | `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `group:web`, `group:knowledge`, `group:media_gen`, `group:media_read`, `group:skills` | +| `messaging` | `group:messaging`, `group:web`, `group:sessions`, `group:media_read`, `skill_search` | | `minimal` | `session_status` only | ### Tool Groups @@ -316,15 +361,18 @@ flowchart TD | Group | Members | |-------|---------| | `fs` | `read_file`, `write_file`, `list_files`, `edit`, `search`, `glob` | -| `runtime` | `exec` | +| `runtime` | `exec`, `credentialed_exec` | | `web` | `web_search`, `web_fetch` | | `memory` | `memory_search`, `memory_get` | | `sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `spawn`, `session_status` | -| `ui` | `browser` | -| `automation` | `cron` | +| `knowledge` | `knowledge_graph_search`, `skill_search` | +| `automation` | `cron`, `datetime` | | `messaging` | `message`, `create_forum_topic` | -| `delegation` | `handoff`, `delegate_search`, `evaluate_loop` | -| `team` | `team_tasks`, `team_message` | +| `delegation` | `delegate` | +| `teams` | `team_tasks`, `team_message` | +| `media_gen` | `create_image`, `create_audio`, `create_video`, `tts` | +| `media_read` | `read_image`, `read_audio`, `read_document`, `read_video` | +| `skills` | `use_skill`, `publish_skill` | | `goclaw` | All native tools (composite group) | Groups can be referenced in allow/deny lists with the `group:` prefix (e.g., `group:fs`). The MCP manager dynamically registers `mcp` and `mcp:{serverName}` groups at runtime. @@ -393,7 +441,7 @@ Delegation allows named agents to delegate tasks to other fully independent agen ### DelegateManager -The `DelegateManager` in `internal/tools/delegate.go` orchestrates all delegation operations: +The subagent system in `internal/tools/subagent_spawn_tool.go` orchestrates all delegation operations: | Action | Mode | Behavior | |--------|------|----------| @@ -413,22 +461,12 @@ type AgentRunFunc func(ctx context.Context, agentKey string, req DelegateRunRequ The `cmd` layer provides the implementation at wiring time. The `tools` package never knows `agent` exists. -### Agent Links (Permission Control) - -Delegation requires an explicit link in the `agent_links` table. Links are directed edges: - -- **outbound** (A→B): Only A can delegate to B -- **bidirectional** (A↔B): Both can delegate to each other - -Each link has `max_concurrent` and per-user `settings` (JSONB) for deny/allow lists. - ### Concurrency Control -Two layers prevent overload: +Delegation concurrency is controlled at the agent level to prevent overload: | Layer | Config | Scope | |-------|--------|-------| -| Per-link | `agent_links.max_concurrent` | A→B specifically | | Per-agent | `other_config.max_delegation_load` | B from all sources | When limits hit, the error message is written for LLM reasoning: *"Agent at capacity (5/5). Try a different agent or handle it yourself."* @@ -438,7 +476,7 @@ When limits hit, the error message is written for LLM reasoning: *"Agent at capa During agent resolution, `DELEGATION.md` is auto-generated and injected into the system prompt: - **≤15 targets**: Full inline list with agent keys, names, and frontmatter -- **>15 targets**: Search instruction pointing to the `delegate_search` tool (hybrid FTS + pgvector cosine) +- **>15 targets**: Brief description-only list (LLM reads available delegation targets via resolver) ### Context File Merging (Open Agents) @@ -493,56 +531,7 @@ Teammate results route through the message bus with a `"teammate:"` prefix. The --- -## 9. Evaluate-Optimize Loop - -A structured revision cycle between two agents: a generator and an evaluator. - -```mermaid -sequenceDiagram - participant L as Calling Agent - participant G as Generator - participant V as Evaluator - - L->>G: "Write product announcement" - G->>L: Draft v1 - L->>V: "Evaluate against criteria" - V->>L: "REJECTED: Too long, missing pricing" - L->>G: "Revise. Feedback: too long, missing pricing" - G->>L: Draft v2 - L->>V: "Evaluate revised version" - V->>L: "APPROVED" - L->>L: Return v2 as final output -``` - -The `evaluate_loop` tool orchestrates this. Parameters: generator agent, evaluator agent, pass criteria, and max rounds (default 3, cap 5). Each round is a pair of sync delegations. If the evaluator responds with "APPROVED" (case-insensitive prefix match), the loop exits. If "REJECTED: feedback", the generator gets another shot. - -Internal delegations use `WithSkipHooks(ctx)` to prevent quality gates from triggering recursion. - ---- - -## 10. Agent Handoff - -Handoff transfers a conversation from one agent to another. Unlike delegation (which keeps the source agent in the loop), handoff removes it entirely. - -| | Delegation | Handoff | -|---|---|---| -| Who talks to the user? | Source agent (always) | Target agent (after transfer) | -| Source agent involvement | Waits for result, reformulates | Steps away completely | -| Session | Target runs in source's context | Target gets a new session | -| Duration | One task | Until cleared or handed back | - -### Mechanism - -When agent A calls `handoff(agent="billing", reason="billing question")`: -1. A row is written to `handoff_routes`: this channel + chat ID now routes to billing -2. A `handoff` event is broadcast (WS clients can react) -3. An initial message is published to billing via the message bus with conversation context - -Subsequent messages from the user on that channel are routed to billing (consumer checks `handoff_routes` before normal routing). Billing can hand back via `handoff(action="clear")`. - ---- - -## 11. Quality Gates (Hook System) +## 9. Quality Gates (Hook System) A general-purpose hook system for validating agent output before it reaches the user. Located in `internal/hooks/`. @@ -584,7 +573,7 @@ Quality gates with agent evaluators can cause infinite recursion (gate delegates --- -## 12. MCP Bridge Tools +## 10. MCP Bridge Tools GoClaw integrates with Model Context Protocol (MCP) servers via `internal/mcp/`. The MCP Manager connects to external tool servers and registers their tools in the tool registry with a configurable prefix. @@ -635,7 +624,7 @@ flowchart LR --- -## 13. Custom Tools +## 11. Custom Tools Define shell-based tools at runtime via the HTTP API -- no recompile or restart needed. Custom tools are stored in the `custom_tools` PostgreSQL table and loaded dynamically into the agent's tool registry. @@ -695,7 +684,7 @@ flowchart TD --- -## 14. Credential Scrubbing +## 12. Credential Scrubbing Tool output is automatically scrubbed before being returned to the LLM. Enabled by default in the registry. @@ -720,7 +709,7 @@ In addition to static patterns, values can be registered at runtime for scrubbin --- -## 15. Rate Limiter +## 13. Rate Limiter The tool registry supports per-session rate limiting via `ToolRateLimiter`. When configured, each `ExecuteWithContext` call checks `rateLimiter.Allow(sessionKey)` before tool execution. Rate-limited calls receive an error result without executing the tool. @@ -728,38 +717,84 @@ The tool registry supports per-session rate limiting via `ToolRateLimiter`. When ## File Reference +### Core Infrastructure +| File | Purpose | +|------|---------| +| `internal/tools/{registry,types,policy,result}.go` | Registry, interfaces, PolicyEngine (7-step pipeline), result types | +| `internal/tools/{context_keys,rate_limiter}.go` | Context key definitions, per-session rate limiting | +| `internal/tools/{scrub,scrub_server}.go` | Credential scrubbing and dynamic value registration | + +### Filesystem Tools +| File | Purpose | +|------|---------| +| `internal/tools/filesystem{,_list,_write}.go` | read_file, write_file, list_files, edit tools | +| `internal/tools/edit.go` | edit tool: targeted file modifications | +| `internal/tools/{context_file,memory,workspace}_interceptor.go` | File routing: context files, memory, team workspace | +| `internal/tools/workspace_dir.go` | Workspace directory resolution for team/user context | + +### Runtime & Shell Tools +| File | Purpose | +|------|---------| +| `internal/tools/shell.go` | exec tool: deny patterns, approval workflow, sandbox routing | +| `internal/tools/exec_approval.go` | Approval workflow for restricted shell commands | +| `internal/tools/credentialed_exec.go` | credentialed_exec: direct exec mode with credential injection | +| `internal/tools/credential_{context,presets}.go` | TOOLS.md supplement + preset definitions (gh, gcloud, aws, etc.) | + +### Web Tools +| File | Purpose | +|------|---------| +| `internal/tools/web_search{,_brave,_ddg}.go` | web_search tool (Brave, DuckDuckGo) | +| `internal/tools/web_fetch{,_convert,_convert_handlers,_convert_utils,_hidden}.go` | web_fetch tool: fetch, HTML→Markdown, element handlers | +| `internal/tools/web_shared.go` | Shared web utilities | + +### Memory, Knowledge & Sessions +| File | Purpose | +|------|---------| +| `internal/tools/{memory,knowledge_graph,skill_search}.go` | Memory search, KG queries, skill BM25 search | +| `internal/tools/sessions{,_history,_send}.go` | Session list, history, send tools | +| `internal/tools/subagent{,_spawn_tool,_config,_exec,_control,_tracing}.go` | SubagentManager: spawn, cancel, steer, tracing | + +### Media Tools +| File | Purpose | +|------|---------| +| `internal/tools/create_image.go` | create_image tool (OpenAI, Gemini, MiniMax, DashScope) | +| `internal/tools/create_image_{dashscope,minimax}.go` | Provider-specific image generation | +| `internal/tools/create_audio.go` | create_audio tool (MiniMax, ElevenLabs, Suno) | +| `internal/tools/create_audio_{minimax,elevenlabs,suno}.go` | Provider-specific audio generation | +| `internal/tools/create_video.go` | create_video tool (MiniMax) | +| `internal/tools/tts.go` | tts tool: text-to-speech (OpenAI, ElevenLabs, Edge, MiniMax) | +| `internal/tools/read_{image,audio,video,document}.go` | Media reading tools (vision, transcription, analysis) | +| `internal/tools/read_{audio,video,document}_resolve.go` | Resolve service integrations | +| `internal/tools/read_document_gemini.go` | Gemini file API for documents | +| `internal/tools/gemini_file_api.go` | Google Gemini file API wrapper | +| `internal/tools/media_provider_chain.go` | Media provider routing and fallback chain | + +### Skills & Content Tools +| File | Purpose | +|------|---------| +| `internal/tools/use_skill.go` | use_skill tool: marker for observability | +| `internal/tools/publish_skill.go` | publish_skill tool: skill registration in database | + +### Team Collaboration Tools +| File | Purpose | +|------|---------| +| `internal/tools/team_tool_manager.go` | Shared backend: team cache (5-min TTL), resolution | +| `internal/tools/team_tasks_tool.go` | team_tasks tool: task board operations | +| `internal/tools/team_tasks_{read,mutations,lifecycle,followup}.go` | Task CRUD, state transitions, dependency handling | +| `internal/tools/team_message_tool.go` | team_message tool: mailbox (send, broadcast, read) | +| `internal/tools/team_tool_{cache,dispatch,helpers,validation}.go` | Team tool infrastructure | +| `internal/tools/team_access_policy.go` | Access control policies for team tools | + +### Messaging, Automation & Custom Tools +| File | Purpose | +|------|---------| +| `internal/tools/{message,telegram_forum,cron,datetime}.go` | Messaging, forum topics, cron scheduling, datetime | +| `internal/tools/announce_queue.go` | Message queueing with debouncing | +| `internal/tools/{dynamic_loader,dynamic_tool}.go` | Dynamic/custom tool loading and execution | +| `internal/tools/openai_compat_call.go` | OpenAI-compatible endpoint calling utilities | + +### Hooks, MCP & Infrastructure | File | Purpose | |------|---------| -| `internal/tools/registry.go` | Registry: Register, Execute, ExecuteWithContext, ProviderDefs | -| `internal/tools/types.go` | Tool interface, ContextualTool, InterceptorAware, and other config interfaces | -| `internal/tools/policy.go` | PolicyEngine: 7-step pipeline, tool groups, profiles, subagent deny lists | -| `internal/tools/filesystem.go` | read_file, write_file, edit_file with interceptor support | -| `internal/tools/filesystem_list.go` | list_files tool | -| `internal/tools/filesystem_write.go` | Additional write operations | -| `internal/tools/shell.go` | ExecTool: deny patterns, approval workflow, sandbox routing | -| `internal/tools/scrub.go` | ScrubCredentials: credential pattern matching and redaction | -| `internal/tools/subagent.go` | SubagentManager: spawn, cancel, steer, run sync, deny lists | -| `internal/tools/delegate.go` | DelegateManager: sync, async, cancel, concurrency, per-user checks | -| `internal/tools/delegate_tool.go` | Delegate tool wrapper (action: delegate/cancel/list/history) | -| `internal/tools/delegate_search_tool.go` | Hybrid FTS + semantic agent discovery | -| `internal/tools/evaluate_loop_tool.go` | Generate-evaluate-revise loop (max 5 rounds) | -| `internal/tools/handoff_tool.go` | Conversation transfer (routing override + context carry) | -| `internal/tools/team_tool_manager.go` | Shared backend for team tools | -| `internal/tools/team_tasks_tool.go` | Task board: list, create, claim, complete, search | -| `internal/tools/team_message_tool.go` | Mailbox: send, broadcast, read | -| `internal/hooks/engine.go` | Hook engine: evaluator registry, EvaluateHooks | -| `internal/hooks/command_evaluator.go` | Shell command evaluator | -| `internal/hooks/agent_evaluator.go` | Agent delegation evaluator | -| `internal/hooks/context.go` | WithSkipHooks / SkipHooksFromContext | -| `internal/tools/context_file_interceptor.go` | ContextFileInterceptor: 7-file routing by agent type | -| `internal/tools/memory_interceptor.go` | MemoryInterceptor: MEMORY.md and memory/* routing | -| `internal/tools/skill_search.go` | Skill search tool (BM25) | -| `internal/tools/tts.go` | Text-to-speech tool (4 providers) | -| `internal/mcp/manager.go` | MCP Manager: server connections, health checks, tool registration | -| `internal/mcp/bridge_tool.go` | MCP bridge tool implementation | -| `internal/tools/dynamic_loader.go` | DynamicLoader: LoadGlobal, LoadForAgent, ReloadGlobal | -| `internal/tools/dynamic_tool.go` | DynamicTool: template rendering, shell escaping, execution | -| `internal/store/custom_tool_store.go` | CustomToolStore interface | -| `internal/store/pg/custom_tools.go` | PostgreSQL custom tools implementation | -| `internal/store/mcp_store.go` | MCPServerStore interface (grants, access requests) | -| `internal/store/pg/mcp_servers.go` | PostgreSQL MCP implementation | +| `internal/hooks/{engine,command_evaluator,agent_evaluator,context}.go` | Hook engine, evaluators, context | +| `internal/mcp/{manager,bridge_tool}.go` | MCP server connections, bridge tool | diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index f00f33137..a10817c07 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -74,7 +74,7 @@ The first request from a client must be `connect`. Any other method sent before ### Event Frame Structure - `type`: always `"event"` -- `event`: event name (e.g., `chat`, `agent`, `status`, `handoff`) +- `event`: event name (e.g., `chat`, `agent`, `status`) - `payload`: event data - `seq`: ordering sequence number - `stateVersion`: version counters for optimistic state sync @@ -110,9 +110,9 @@ flowchart LR | Role | Accessible Methods | |------|--------------------| -| viewer | `agents.list`, `config.get`, `sessions.list`, `sessions.preview`, `health`, `status`, `models.list`, `skills.list`, `skills.get`, `channels.list`, `channels.status`, `cron.list`, `cron.status`, `cron.runs`, `usage.get`, `usage.summary` | +| viewer | `agents.list`, `config.get`, `sessions.list`, `sessions.preview`, `health`, `status`, `providers.models`, `skills.list`, `skills.get`, `channels.list`, `channels.status`, `cron.list`, `cron.status`, `cron.runs`, `usage.get`, `usage.summary` | | operator | All viewer methods plus: `chat.send`, `chat.abort`, `chat.history`, `chat.inject`, `sessions.delete`, `sessions.reset`, `sessions.patch`, `cron.create`, `cron.update`, `cron.delete`, `cron.toggle`, `cron.run`, `skills.update`, `send`, `exec.approval.list`, `exec.approval.approve`, `exec.approval.deny`, `device.pair.request`, `device.pair.list` | -| admin | All operator methods plus: `config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `agents.links.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` | +| admin | All operator methods plus: `config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` | --- @@ -142,7 +142,7 @@ flowchart TD | `connect` | Authentication handshake (must be first request) | | `health` | Health check | | `status` | Gateway status (connected clients, agents, channels) | -| `models.list` | List available models from all providers | +| `providers.models` | List available models from all providers | ### Chat @@ -261,15 +261,6 @@ flowchart TD | `browser.snapshot` | Get DOM snapshot | | `browser.screenshot` | Take screenshot | -### Agent Links - -| Method | Description | -|--------|-------------| -| `agents.links.list` | List agent links (by source agent) | -| `agents.links.create` | Create an agent link (outbound or bidirectional) | -| `agents.links.update` | Update a link (max_concurrent, settings, status) | -| `agents.links.delete` | Delete an agent link | - ### Teams | Method | Description | @@ -278,7 +269,26 @@ flowchart TD | `teams.create` | Create a team (lead + members) | | `teams.get` | Get team details with members | | `teams.delete` | Delete a team | +| `teams.update` | Update team configuration | | `teams.tasks.list` | List team tasks | +| `teams.tasks.get` | Get task details | +| `teams.tasks.create` | Create a new task | +| `teams.tasks.delete` | Delete a task | +| `teams.tasks.claim` | Claim a task (mark as in-progress) | +| `teams.tasks.assign` | Assign task to member | +| `teams.tasks.approve` | Approve completed task | +| `teams.tasks.reject` | Reject task submission | +| `teams.tasks.comment` | Add comment to task | +| `teams.tasks.comments` | Get task comments | +| `teams.tasks.events` | Get task event history | +| `teams.members.add` | Add member to team | +| `teams.members.remove` | Remove member from team | +| `teams.workspace.list` | List team workspace files | +| `teams.workspace.read` | Read workspace file content | +| `teams.workspace.delete` | Delete workspace file | +| `teams.events.list` | List team event history | +| `teams.known_users` | Get list of known users for team | +| `teams.scopes` | Get team member scopes | ### Delegations @@ -287,6 +297,30 @@ flowchart TD | `delegations.list` | List delegation history (result truncated to 500 runes) | | `delegations.get` | Get delegation detail (result truncated to 8000 runes) | +### Channel Instances + +| Method | Description | +|--------|-------------| +| `channels.instances.list` | List channel instances | +| `channels.instances.get` | Get channel instance details | +| `channels.instances.create` | Create a new channel instance | +| `channels.instances.update` | Update channel instance config | +| `channels.instances.delete` | Delete a channel instance | + +### API Keys + +| Method | Description | +|--------|-------------| +| `api_keys.list` | List API keys | +| `api_keys.create` | Create a new API key | +| `api_keys.revoke` | Revoke an API key | + +### Usage and Quotas + +| Method | Description | +|--------|-------------| +| `quota.usage` | Get quota usage information | + ### Other | Method | Description | @@ -384,15 +418,6 @@ All CRUD endpoints require `Authorization: Bearer ` and `X-GoClaw-User-Id | POST | `/v1/agents/{id}/sharing` | Share agent with a user | | DELETE | `/v1/agents/{id}/sharing/{userID}` | Revoke user access | -**Agent Links** (`/v1/agents/{id}/links`): - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/v1/agents/{id}/links` | List links for an agent | -| POST | `/v1/agents/{id}/links` | Create a new link | -| PUT | `/v1/agents/{id}/links/{linkID}` | Update a link | -| DELETE | `/v1/agents/{id}/links/{linkID}` | Delete a link | - **Delegations** (`/v1/delegations`): | Method | Path | Description | @@ -415,6 +440,88 @@ All CRUD endpoints require `Authorization: Bearer ` and `X-GoClaw-User-Id | GET | `/v1/traces` | List traces (filter by agent_id, user_id, status, date range) | | GET | `/v1/traces/{id}` | Get trace details with all spans | +**Channel Instances** (`/v1/channel-instances`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/channel-instances` | List channel instances | +| POST | `/v1/channel-instances` | Create a new channel instance | +| GET | `/v1/channel-instances/{id}` | Get channel instance details | +| PUT | `/v1/channel-instances/{id}` | Update channel instance config | +| DELETE | `/v1/channel-instances/{id}` | Delete a channel instance | + +**API Keys** (`/v1/api-keys`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/api-keys` | List API keys | +| POST | `/v1/api-keys` | Create a new API key | +| DELETE | `/v1/api-keys/{id}` | Revoke an API key | + +**Providers & Models** (`/v1/providers`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/providers` | List LLM providers | +| POST | `/v1/providers` | Create a new provider | +| GET | `/v1/providers/{id}` | Get provider details | +| PUT | `/v1/providers/{id}` | Update provider config | +| DELETE | `/v1/providers/{id}` | Delete a provider | + +**Memory** (`/v1/memory`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/memory` | Get memory entries | +| POST | `/v1/memory` | Create memory entry | +| DELETE | `/v1/memory/{id}` | Delete memory entry | + +**Knowledge Graph** (`/v1/kg`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/kg/entities` | List entities | +| POST | `/v1/kg/entities` | Create entity | +| GET | `/v1/kg/relations` | List relationships | +| POST | `/v1/kg/relations` | Create relationship | + +**Files & Storage** (`/v1/files`, `/v1/storage`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/files` | List workspace files | +| GET | `/v1/files/{path}` | Serve file content | +| DELETE | `/v1/storage/{path}` | Delete workspace file | + +**Media** (`/v1/media`): + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/v1/media/upload` | Upload media file | +| GET | `/v1/media/{id}` | Serve media file | + +**Activity & Usage** (`/v1/activity`, `/v1/usage`): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/activity` | List activity audit logs | +| GET | `/v1/usage` | Get usage metrics | +| GET | `/v1/usage/summary` | Get aggregated usage summary | + +**OAuth & Docs** (`/oauth`, `/docs`): + +| Method | Path | Description | +|--------|------|-------------| +| GET,POST | `/oauth/*` | OAuth authentication endpoints | +| GET | `/docs/openapi.json` | OpenAPI specification | +| GET | `/docs/swagger-ui/` | Swagger UI | + +**MCP Bridge** (`/mcp/bridge`): + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/mcp/bridge` | MCP server bridge (Claude CLI tools) | + --- ## 7. Rate Limiting @@ -471,22 +578,43 @@ Error responses include `retryable` (boolean) and `retryAfterMs` (integer) field | `internal/gateway/methods/config.go` | config.get/apply/patch/schema handlers | | `internal/gateway/methods/skills.go` | skills.list/get/update handlers | | `internal/gateway/methods/cron.go` | cron.list/create/update/delete/toggle/run/runs handlers | -| `internal/gateway/methods/agent_links.go` | agents.links.* handlers + agent router cache invalidation | | `internal/gateway/methods/teams.go` | teams.* handlers + auto-linking teammates | +| `internal/gateway/methods/teams_workspace.go` | teams.workspace.* handlers (file management) | | `internal/gateway/methods/delegations.go` | delegations.list/get handlers | -| `internal/gateway/methods/channels.go` | channels.list/status handlers | -| `internal/gateway/methods/pairing.go` | device.pair.* handlers | +| `internal/gateway/methods/channels.go` | channels.list/status/toggle handlers | +| `internal/gateway/methods/channel_instances.go` | channels.instances.* handlers (CRUD) | +| `internal/gateway/methods/pairing.go` | device.pair.* and browser.pairing.* handlers | | `internal/gateway/methods/exec_approval.go` | exec.approval.* handlers | | `internal/gateway/methods/usage.go` | usage.get/summary handlers | +| `internal/gateway/methods/api_keys.go` | api_keys.list/create/revoke handlers | | `internal/gateway/methods/send.go` | send handler (direct message to channel) | | `internal/http/chat_completions.go` | POST /v1/chat/completions (OpenAI-compatible) | | `internal/http/responses.go` | POST /v1/responses (OpenResponses protocol) | | `internal/http/tools_invoke.go` | POST /v1/tools/invoke (direct tool execution) | -| `internal/http/agents.go` | Agent CRUD HTTP handlers | -| `internal/http/skills.go` | Skills HTTP handlers | -| `internal/http/traces.go` | Traces HTTP handlers | -| `internal/http/delegations.go` | Delegation history HTTP handlers | +| `internal/http/agents.go` | Agent CRUD HTTP handlers (/v1/agents, /v1/agents/{id}/sharing) | +| `internal/http/skills.go` | Skills HTTP handlers (/v1/skills, upload, dependencies) | +| `internal/http/traces.go` | Traces HTTP handlers (/v1/traces) | +| `internal/http/delegations.go` | Delegation history HTTP handlers (/v1/delegations) | +| `internal/http/channel_instances.go` | Channel instance CRUD handlers (/v1/channel-instances) | +| `internal/http/providers.go` | LLM provider CRUD handlers (/v1/providers) | +| `internal/http/memory.go` | Memory management handlers (/v1/memory) | +| `internal/http/knowledge_graph.go` | Knowledge graph handlers (/v1/kg) | +| `internal/http/files.go` | Workspace file serving handlers (/v1/files) | +| `internal/http/storage.go` | Storage file CRUD handlers (/v1/storage) | +| `internal/http/media_upload.go` | Media upload handlers (/v1/media/upload) | +| `internal/http/media_serve.go` | Media serving handlers (/v1/media/{id}) | +| `internal/http/activity.go` | Activity audit log handlers (/v1/activity) | +| `internal/http/usage.go` | Usage analytics handlers (/v1/usage) | +| `internal/http/api_keys.go` | API key management handlers (/v1/api-keys) | +| `internal/http/custom_tools.go` | Custom tool CRUD handlers (/v1/tools/custom) | +| `internal/http/mcp.go` | MCP server management handlers (/v1/mcp) | | `internal/http/summoner.go` | LLM-powered agent setup (XML parsing, context file generation) | | `internal/http/auth.go` | Bearer token authentication, timing-safe comparison | +| `internal/http/oauth.go` | OAuth authentication endpoints (/oauth) | +| `internal/http/docs.go` | OpenAPI documentation handlers (/docs) | +| `internal/mcp/bridge.go` | MCP bridge for Claude CLI integration (/mcp/bridge) | | `internal/permissions/policy.go` | PolicyEngine: role hierarchy, method-to-role mapping | | `pkg/protocol/frames.go` | Frame types: RequestFrame, ResponseFrame, EventFrame, ErrorShape | +| `pkg/protocol/methods.go` | RPC method name constants (Phase 1-3) | +| `pkg/protocol/events.go` | WebSocket event names and event subtypes | +| `pkg/protocol/errors.go` | Error code constants and error factories | diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index c3ae70464..2d67e81ae 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -55,11 +55,7 @@ flowchart LR SEND --> WA ``` -Internal channels (`cli`, `system`, `subagent`) are silently skipped by the outbound dispatcher and never forwarded to external platforms. - -### Handoff Routing - -Before normal agent routing, the consumer checks the `handoff_routes` table for an active routing override. If a handoff route exists for the incoming channel + chat ID, the message is redirected to the target agent instead of the original. +Internal channels (`cli`, `system`, `subagent`, `browser`) are silently skipped by the outbound dispatcher and never forwarded to external platforms. The `browser` channel uses WebSocket directly on the gateway connection. ### Message Routing Prefixes @@ -70,7 +66,6 @@ The consumer routes system messages based on sender ID prefixes: | `subagent:` | Parent session queue | subagent | | `delegate:` | Parent agent's original session | delegate | | `teammate:` | Target agent session | delegate | -| `handoff:` | Target agent via delegate lane | delegate | --- @@ -80,7 +75,8 @@ Every channel must implement the base interface: | Method | Description | |--------|-------------| -| `Name()` | Channel identifier (e.g., `"telegram"`, `"discord"`) | +| `Name()` | Channel instance name (e.g., `"telegram"`, `"discord"`) | +| `Type()` | Platform type identifier (e.g., `"telegram"`, `"zalo_personal"`). For config-based channels equals `Name()`; for DB instances may differ. | | `Start(ctx)` | Begin listening for messages (non-blocking) | | `Stop(ctx)` | Graceful shutdown | | `Send(ctx, msg)` | Deliver an outbound message to the platform | @@ -91,9 +87,10 @@ Every channel must implement the base interface: | Interface | Purpose | Implemented By | |-----------|---------|----------------| -| `StreamingChannel` | Real-time streaming updates | Telegram, Feishu | +| `StreamingChannel` | Real-time streaming updates | Telegram, Slack | | `WebhookChannel` | Webhook HTTP handler mounting | Feishu | -| `ReactionChannel` | Status reactions on messages | Telegram, Feishu | +| `ReactionChannel` | Status reactions on messages | Telegram, Slack, Feishu | +| `BlockReplyChannel` | Override gateway block_reply setting | Slack | `BaseChannel` provides a shared implementation that all channels embed: allowlist matching, `HandleMessage()`, `CheckPolicy()`, and user ID extraction. @@ -405,7 +402,7 @@ The Slack channel uses the `slack-go/slack` library to connect via Socket Mode ( - **Message debounce**: Per-thread batching of rapid messages (300ms default, configurable) - **Dead socket classification**: Non-retryable auth errors (invalid_auth, token_revoked) fail fast instead of infinite reconnect - **Streaming**: Edit-in-place via `chat.update` with 1000ms throttle (Slack Tier 3 rate limit) -- **Reactions**: Status emoji on user messages (thinking_face, hammer_and_wrench, white_check_mark, x, hourglass) +- **Reactions**: Status emoji on user messages (thinking_face, hammer_and_wrench, white_check_mark, x, hourglass_flowing_sand) - **SSRF protection**: File download hostname allowlist (*.slack.com, *.slack-edge.com, *.slack-files.com), auth token stripped on redirect - **Health probe**: `auth.test()` with 2.5s timeout for monitoring integration @@ -568,8 +565,9 @@ flowchart TD | File | Purpose | |------|---------| -| `internal/channels/channel.go` | Channel interface, BaseChannel, extended interfaces, HandleMessage | -| `internal/channels/manager.go` | Manager: registration, StartAll, StopAll, outbound dispatch, webhook collection | +| `internal/channels/channel.go` | Channel interface, BaseChannel, extended interfaces, HandleMessage, Type() method | +| `internal/channels/manager.go` | Manager: registration, StartAll, StopAll, channel lifecycle, webhook collection | +| `internal/channels/dispatch.go` | Outbound message dispatcher, send error formatting | | `internal/channels/instance_loader.go` | DB-based channel instance loading | | `internal/channels/telegram/channel.go` | Telegram core: long polling, mention gating, typing indicators | | `internal/channels/telegram/handlers.go` | Message handling, media processing, forum topic detection | @@ -579,21 +577,24 @@ flowchart TD | `internal/channels/telegram/stream.go` | Streaming placeholder management | | `internal/channels/telegram/reactions.go` | Status reactions on messages | | `internal/channels/telegram/format.go` | Markdown → Telegram HTML pipeline, table rendering | -| `internal/channels/feishu/feishu.go` | Feishu core: WS/Webhook modes, config | -| `internal/channels/feishu/streaming.go` | Streaming card create/update/close | +| `internal/channels/feishu/feishu.go` | Feishu core: WS/Webhook modes, config, reactions | +| `internal/channels/feishu/larkclient_messaging.go` | Streaming card create/update/close, message sending | | `internal/channels/feishu/media.go` | Media upload/download, type detection | | `internal/channels/feishu/bot_parse.go` | Mention resolution, message event parsing | | `internal/channels/feishu/bot.go` | Bot message handlers | | `internal/channels/feishu/bot_policy.go` | Policy evaluation | -| `internal/channels/discord/discord.go` | Discord: gateway events, placeholder editing | -| `internal/channels/slack/slack.go` | Slack: Socket Mode, mention gating, thread caching | +| `internal/channels/discord/discord.go` | Discord: gateway setup, session management, lifecycle | +| `internal/channels/discord/handler.go` | Message handling, typing indicators, placeholder management | +| `internal/channels/slack/channel.go` | Slack: Socket Mode, mention gating, thread caching, streaming | +| `internal/channels/slack/handlers.go` | Message and event handling, pairing, group policy | | `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline | | `internal/channels/slack/reactions.go` | Status emoji reactions on messages | +| `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing | | `internal/channels/whatsapp/whatsapp.go` | WhatsApp: external WS bridge | | `internal/channels/zalo/zalo.go` | Zalo OA: Bot API, long polling | | `internal/channels/zalo/personal/channel.go` | Zalo Personal: reverse-engineered protocol | | `internal/store/pg/pairing.go` | Pairing: code generation, approval, persistence (database-backed) | -| `cmd/gateway_consumer.go` | Message routing: prefixes, handoff, cancel interception | +| `cmd/gateway_consumer.go` | Message routing: prefixes, cancel interception | --- diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index c670e4329..633e76056 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -10,7 +10,7 @@ The store layer abstracts all persistence behind Go interfaces backed by Postgre flowchart TD START["Gateway Startup"] --> PG["PostgreSQL Backend"] - PG --> PG_STORES["PGSessionStore
PGAgentStore
PGProviderStore
PGCronStore
PGPairingStore
PGSkillStore
PGMemoryStore
PGTracingStore
PGMCPServerStore
PGCustomToolStore
PGChannelInstanceStore
PGConfigSecretsStore
PGAgentLinkStore
PGTeamStore"] + PG --> PG_STORES["PGSessionStore
PGMemoryStore
PGCronStore
PGPairingStore
PGSkillStore
PGAgentStore
PGProviderStore
PGTracingStore
PGMCPServerStore
PGCustomToolStore
PGChannelInstanceStore
PGConfigSecretsStore
PGTeamStore
PGBuiltinToolStore
PGPendingMessageStore
PGKnowledgeGraphStore
PGContactStore
PGActivityStore
PGSnapshotStore
PGSecureCLIStore
PGAPIKeyStore"] ``` --- @@ -19,22 +19,29 @@ flowchart TD The `Stores` struct is the top-level container holding all PostgreSQL-backed storage implementations. -| Interface | Implementation | -|-----------|---------------| -| SessionStore | `PGSessionStore` | -| MemoryStore | `PGMemoryStore` (tsvector + pgvector) | -| CronStore | `PGCronStore` | -| PairingStore | `PGPairingStore` | -| SkillStore | `PGSkillStore` | -| AgentStore | `PGAgentStore` | -| ProviderStore | `PGProviderStore` | -| TracingStore | `PGTracingStore` | -| MCPServerStore | `PGMCPServerStore` | -| CustomToolStore | `PGCustomToolStore` | -| ChannelInstanceStore | `PGChannelInstanceStore` | -| ConfigSecretsStore | `PGConfigSecretsStore` | -| AgentLinkStore | `PGAgentLinkStore` | -| TeamStore | `PGTeamStore` | +| Interface | Implementation | Purpose | +|-----------|---|---------| +| SessionStore | `PGSessionStore` | Conversation history with in-memory write-behind cache | +| MemoryStore | `PGMemoryStore` | Memory documents, embedding, FTS, hybrid search (tsvector + pgvector) | +| CronStore | `PGCronStore` | Scheduled job definitions and execution logs | +| PairingStore | `PGPairingStore` | Browser pairing codes and paired device tracking | +| SkillStore | `PGSkillStore` | SKILL.md definitions, BM25 search, agent/user grants | +| AgentStore | `PGAgentStore` | Agent definitions, soft delete, RBAC sharing, access control | +| ProviderStore | `PGProviderStore` | LLM provider configs, encrypted API keys, model listings | +| TracingStore | `PGTracingStore` | LLM call traces, spans, observability aggregation | +| MCPServerStore | `PGMCPServerStore` | MCP server configs, transport (stdio/sse), tool grants | +| CustomToolStore | `PGCustomToolStore` | Dynamic tool definitions, shell command templates, agent/global scoping | +| ChannelInstanceStore | `PGChannelInstanceStore` | Channel instance configs (Telegram account, Discord guild, etc.) | +| ConfigSecretsStore | `PGConfigSecretsStore` | Encrypted configuration secrets (AES-256-GCM) | +| TeamStore | `PGTeamStore` | Teams, tasks (atomic claim), members, messages, delegation history | +| BuiltinToolStore | `PGBuiltinToolStore` | System tool metadata, enable/disable toggles, settings | +| PendingMessageStore | `PGPendingMessageStore` | Offline group chat message queue, auto-compaction to summaries | +| KnowledgeGraphStore | `PGKnowledgeGraphStore` | Entity-relationship graphs, traversal, inference extraction | +| ContactStore | `PGContactStore` | Channel contacts (auto-collected), cross-channel deduplication, merge | +| ActivityStore | `PGActivityStore` | Audit logs, action tracking, compliance | +| SnapshotStore | `PGSnapshotStore` | Hourly usage snapshots, cost aggregation, time series queries | +| SecureCLIStore | `PGSecureCLIStore` | CLI binary configs with encrypted credential injection | +| APIKeyStore | `PGAPIKeyStore` | Gateway API keys, scopes, expiration, revocation | --- @@ -240,43 +247,7 @@ Dynamic tool definitions stored in PostgreSQL. Each tool defines a shell command --- -## 10. Agent Link Store - -The agent link store manages inter-agent delegation permissions -- directed edges that control which agents can delegate to which others. - -### Table: `agent_links` - -| Column | Type | Description | -|--------|------|-------------| -| `id` | UUID v7 | Primary key | -| `source_agent_id` | UUID | Agent that can delegate (FK → agents) | -| `target_agent_id` | UUID | Agent being delegated to (FK → agents) | -| `direction` | VARCHAR(20) | `outbound` (A→B only), `bidirectional` (A↔B) | -| `team_id` | UUID | Non-nil = auto-created by team setup (FK → agent_teams, SET NULL on delete) | -| `description` | TEXT | Link description | -| `max_concurrent` | INT | Per-link concurrency cap (default 3) | -| `settings` | JSONB | Per-user deny/allow lists for fine-grained access control | -| `status` | VARCHAR(20) | `active` or `disabled` | -| `created_by` | VARCHAR | Audit trail | - -**Constraints**: `UNIQUE(source_agent_id, target_agent_id)`, `CHECK (source_agent_id != target_agent_id)` - -### Agent Search Columns (migration 000002) - -The `agents` table gains three columns for agent discovery during delegation: - -| Column | Type | Purpose | -|--------|------|---------| -| `frontmatter` | TEXT | Short expertise summary (distinct from `other_config.description` which is the summoning prompt) | -| `tsv` | TSVECTOR | Auto-generated from `display_name + frontmatter`, GIN-indexed | -| `embedding` | VECTOR(1536) | For cosine similarity search, HNSW-indexed | - -### AgentLinkStore Interface (12 methods) - -- **CRUD**: `CreateLink`, `DeleteLink`, `UpdateLink`, `GetLink` -- **Queries**: `ListLinksFrom(agentID)`, `ListLinksTo(agentID)` -- **Permission**: `CanDelegate(from, to)`, `GetLinkBetween(from, to)` (returns full link with Settings for per-user checks) -- **Discovery**: `DelegateTargets(agentID)` (all targets with joined agent_key + display_name for DELEGATION.md), `SearchDelegateTargets` (FTS), `SearchDelegateTargetsByEmbedding` (vector cosine) +## 10. Delegation History ### Table: `delegation_history` @@ -304,7 +275,7 @@ Every sync and async delegation is persisted here automatically via `SaveDelegat ## 11. Team Store -The team store manages collaborative multi-agent teams with a shared task board, peer-to-peer mailbox, and handoff routing. +The team store manages collaborative multi-agent teams with a shared task board and peer-to-peer mailbox. ### Tables @@ -314,7 +285,6 @@ The team store manages collaborative multi-agent teams with a shared task board, | `agent_team_members` | Team membership | PK `(team_id, agent_id)`, `role` (lead/member) | | `team_tasks` | Shared task board | `subject`, `status` (pending/in_progress/completed/blocked), `owner_agent_id`, `blocked_by` (UUID[]), `priority`, `result`, `tsv` (FTS) | | `team_messages` | Peer-to-peer mailbox | `from_agent_id`, `to_agent_id` (NULL = broadcast), `content`, `message_type` (chat/broadcast), `read` | -| `handoff_routes` | Active routing overrides | UNIQUE `(channel, chat_id)`, `from_agent_key`, `to_agent_key`, `reason` | ### TeamStore Interface (22 methods) @@ -326,8 +296,6 @@ The team store manages collaborative multi-agent teams with a shared task board, **Delegation History**: `SaveDelegationHistory`, `ListDelegationHistory` (with filter opts), `GetDelegationHistory` -**Handoff Routes**: `SetHandoffRoute`, `GetHandoffRoute`, `ClearHandoffRoute` - **Messages**: `SendMessage`, `GetUnread`, `MarkRead` ### Atomic Task Claiming @@ -348,7 +316,119 @@ Tasks can declare `blocked_by` (UUID array) pointing to prerequisite tasks. When --- -## 12. Database Schema +## 12. Additional Store Interfaces + +### BuiltinToolStore + +System tool metadata storage. Built-in tools are seeded at startup with category, settings, and dependency metadata. Only `enabled` and `settings` are user-editable. + +| Method | Purpose | +|--------|---------| +| `List()` | Return all tool definitions | +| `Get(name)` | Fetch tool by name | +| `Update(name, updates)` | Modify settings or enabled status | +| `Seed(tools)` | Populate tools at startup | +| `ListEnabled()` | Return only enabled tools | +| `GetSettings(name)` | Fetch settings JSON for a tool | + +### PendingMessageStore + +Offline message queue for group chats. Buffers messages when the bot is not actively listening, auto-compacts into summaries to prevent unbounded growth. + +| Method | Purpose | +|--------|---------| +| `AppendBatch(msgs)` | Insert multiple messages in one query | +| `ListByKey(channelName, historyKey)` | Retrieve buffered messages for a group | +| `DeleteByKey(channelName, historyKey)` | Clear messages after processing | +| `Compact(deleteIDs, summary)` | Atomically delete old messages + insert summary | +| `DeleteStale(olderThan)` | Prune messages older than duration | +| `ListGroups()` | Return distinct channel+key groups with counts | +| `CountAll()` | Total pending messages across all groups | +| `ResolveGroupTitles(groups)` | Look up chat titles from session metadata | + +### KnowledgeGraphStore + +Entity-relationship graph storage for AI inference and knowledge extraction. Supports graph traversal, confidence pruning, and bulk ingestion. + +| Method | Purpose | +|--------|---------| +| `UpsertEntity(entity)` | Create or update entity node | +| `GetEntity(agentID, userID, entityID)` | Fetch single entity | +| `DeleteEntity(agentID, userID, entityID)` | Remove entity (cascades relations) | +| `ListEntities(agentID, userID, opts)` | List with pagination and type filter | +| `SearchEntities(agentID, userID, query, limit)` | Full-text search entities | +| `UpsertRelation(relation)` | Create or update edge | +| `DeleteRelation(agentID, userID, relationID)` | Remove edge | +| `ListRelations(agentID, userID, entityID)` | Get edges connected to an entity | +| `Traverse(agentID, userID, startEntityID, maxDepth)` | Breadth-first graph traversal | +| `IngestExtraction(agentID, userID, entities, relations)` | Bulk insert from LLM extraction | +| `PruneByConfidence(agentID, userID, minConfidence)` | Remove low-confidence nodes/edges | +| `Stats(agentID, userID)` | Aggregate entity and relation counts | + +### ContactStore + +Auto-collected channel contact registry. Tracks users across platforms and supports cross-channel deduplication (merge contacts as same person). + +| Method | Purpose | +|--------|---------| +| `UpsertContact(...)` | Create or update contact; on conflict (channel_type, sender_id) updates metadata | +| `ListContacts(opts)` | Search with pagination and filters (ILIKE on name/username/sender_id) | +| `CountContacts(opts)` | Count matching contacts | +| `GetContactsBySenderIDs(senderIDs)` | Batch lookup contacts by sender IDs | +| `MergeContacts(contactIDs)` | Link multiple contacts as same person (set merged_id) | + +### ActivityStore + +Audit logging for compliance and troubleshooting. Logs all significant actions with actor, entity, and optional details. + +| Method | Purpose | +|--------|---------| +| `Log(entry)` | Record a single audit entry | +| `List(opts)` | Retrieve audit logs with filters (actor_type, action, entity_type, etc.) | +| `Count(opts)` | Count matching audit entries | + +### SnapshotStore + +Pre-computed usage snapshots (hourly aggregations) for analytics dashboards. Tracks token usage, cost, request counts, and tool utilization. + +| Method | Purpose | +|--------|---------| +| `UpsertSnapshots(snapshots)` | Insert or replace batch of hourly aggregations | +| `GetTimeSeries(query)` | Fetch hourly or daily time series for charting | +| `GetBreakdown(query)` | Aggregate by dimension (provider, model, channel, agent) | +| `GetLatestBucket()` | Return most recent bucket_hour (worker resume point) | + +### SecureCLIStore + +CLI binary credential configuration with encrypted environment variable injection. Credentials are auto-injected into child processes without exposing them to command output. + +| Method | Purpose | +|--------|---------| +| `Create(binary)` | Register new CLI binary config | +| `Get(id)` | Fetch config by ID | +| `Update(id, updates)` | Modify settings (enable/disable, denyArgs, etc.) | +| `Delete(id)` | Remove config | +| `List()` | Return all configs | +| `ListByAgent(agentID)` | Return configs for a specific agent | +| `LookupByBinary(binaryName, agentID)` | Find best-matching config (agent-specific > global) | +| `ListEnabled()` | Return enabled configs for TOOLS.md generation | + +### APIKeyStore + +Gateway API key management. Keys are SHA-256 hashed at rest; validation compares hash to incoming key. Supports scopes, expiration, and revocation. + +| Method | Purpose | +|--------|---------| +| `Create(key)` | Insert new API key record | +| `GetByHash(keyHash)` | Lookup active (non-revoked, non-expired) key by hash | +| `List()` | Return all keys for admin display (hashes omitted) | +| `Revoke(id)` | Mark key as revoked | +| `Delete(id)` | Permanently remove key | +| `TouchLastUsed(id)` | Update last_used_at timestamp | + +--- + +## 14. Database Schema All tables use UUID v7 (time-ordered) as primary keys via `GenNewID()`. @@ -365,21 +445,12 @@ flowchart TD AG --> UAP["user_agent_profiles"] end - subgraph "Agent Links" - AG --> AL["agent_links"] - AL --> DH["delegation_history"] - end - subgraph Teams AT["agent_teams"] --> ATM["agent_team_members"] AT --> TT["team_tasks"] AT --> TM["team_messages"] end - subgraph Handoff - HR["handoff_routes"] - end - subgraph Sessions SE["sessions"] end @@ -426,13 +497,11 @@ flowchart TD | `agent_context_files` | Agent-level context | UNIQUE(agent_id, file_name) | | `user_context_files` | Per-user context | UNIQUE(agent_id, user_id, file_name) | | `user_agent_profiles` | User tracking | `first_seen_at`, `last_seen_at`, `workspace` | -| `agent_links` | Inter-agent delegation permissions | UNIQUE(source, target), `direction`, `max_concurrent`, `settings` (JSONB) | | `agent_teams` | Team definitions | `name`, `lead_agent_id`, `status`, `settings` (JSONB) | | `agent_team_members` | Team membership | PK(team_id, agent_id), `role` (lead/member) | | `team_tasks` | Shared task board | `subject`, `status`, `owner_agent_id`, `blocked_by` (UUID[]), `tsv` (FTS) | | `team_messages` | Peer-to-peer mailbox | `from_agent_id`, `to_agent_id`, `message_type`, `read` | | `delegation_history` | Persisted delegation records | `source_agent_id`, `target_agent_id`, `mode`, `status`, `result`, `trace_id` | -| `handoff_routes` | Active routing overrides | UNIQUE(channel, chat_id), `from_agent_key`, `to_agent_key` | | `sessions` | Conversation history | `session_key` (UNIQUE), `messages` (JSONB), `summary`, token counts | | `memory_documents` | Memory docs | UNIQUE(agent_id, COALESCE(user_id, ''), path) | | `memory_chunks` | Chunked + embedded text | `embedding` (VECTOR), `tsv` (TSVECTOR) | @@ -452,7 +521,7 @@ flowchart TD | `000002_agent_links` | `agent_links` table + `frontmatter`, `tsv`, `embedding` on agents + `parent_trace_id` on traces | | `000003_agent_teams` | `agent_teams`, `agent_team_members`, `team_tasks`, `team_messages` + `team_id` on agent_links | | `000004_teams_v2` | FTS on `team_tasks` (tsv column) + `delegation_history` table | -| `000005_phase4` | `handoff_routes` table | +| `000005_phase4` | Additional team and delegation features | ### Required PostgreSQL Extensions @@ -461,7 +530,7 @@ flowchart TD --- -## 13. Context Propagation +## 15. Context Propagation Metadata flows through `context.Context` instead of mutable state, ensuring thread safety across concurrent agent runs. @@ -494,7 +563,7 @@ flowchart TD --- -## 14. Key PostgreSQL Patterns +## 16. Key PostgreSQL Patterns ### Database Driver @@ -532,19 +601,18 @@ All "create or update" operations use `INSERT ... ON CONFLICT DO UPDATE`, ensuri --- -## File Reference +## 17. File Reference | File | Purpose | |------|---------| -| `internal/store/stores.go` | `Stores` container struct (all 14 store interfaces) | +| `internal/store/stores.go` | `Stores` container struct (all 22 store interfaces) | | `internal/store/types.go` | `BaseModel`, `StoreConfig`, `GenNewID()` | | `internal/store/context.go` | Context propagation: `WithUserID`, `WithAgentID`, `WithAgentType`, `WithSenderID` | | `internal/store/session_store.go` | `SessionStore` interface, `SessionData`, `SessionInfo` | | `internal/store/memory_store.go` | `MemoryStore` interface, `MemorySearchResult`, `EmbeddingProvider` | | `internal/store/skill_store.go` | `SkillStore` interface | | `internal/store/agent_store.go` | `AgentStore` interface | -| `internal/store/agent_link_store.go` | `AgentLinkStore` interface, `AgentLinkData`, link constants | -| `internal/store/team_store.go` | `TeamStore` interface, `TeamData`, `TeamTaskData`, `DelegationHistoryData`, `HandoffRouteData`, `TeamMessageData` | +| `internal/store/team_store.go` | `TeamStore` interface, `TeamData`, `TeamTaskData`, `DelegationHistoryData`, `TeamMessageData` | | `internal/store/provider_store.go` | `ProviderStore` interface | | `internal/store/tracing_store.go` | `TracingStore` interface, `TraceData`, `SpanData` | | `internal/store/mcp_store.go` | `MCPServerStore` interface, grant types, access request types | @@ -553,12 +621,19 @@ All "create or update" operations use `INSERT ... ON CONFLICT DO UPDATE`, ensuri | `internal/store/pairing_store.go` | `PairingStore` interface | | `internal/store/cron_store.go` | `CronStore` interface | | `internal/store/custom_tool_store.go` | `CustomToolStore` interface | +| `internal/store/builtin_tool_store.go` | `BuiltinToolStore` interface, system tool metadata | +| `internal/store/pending_message_store.go` | `PendingMessageStore` interface, group message queue | +| `internal/store/knowledge_graph_store.go` | `KnowledgeGraphStore` interface, entities and relations | +| `internal/store/contact_store.go` | `ContactStore` interface, channel contact tracking | +| `internal/store/activity_store.go` | `ActivityStore` interface, audit logs | +| `internal/store/snapshot_store.go` | `SnapshotStore` interface, usage aggregation | +| `internal/store/secure_cli_store.go` | `SecureCLIStore` interface, CLI credential injection | +| `internal/store/api_key_store.go` | `APIKeyStore` interface, gateway API keys | | `internal/store/pg/factory.go` | PG store factory: creates all PG store instances from a connection pool | | `internal/store/pg/sessions.go` | `PGSessionStore`: session cache, Save, GetOrCreate | | `internal/store/pg/agents.go` | `PGAgentStore`: CRUD, soft delete, access control | | `internal/store/pg/agents_context.go` | Agent and user context file operations | -| `internal/store/pg/agent_links.go` | `PGAgentLinkStore`: link CRUD, permissions, FTS + vector search | -| `internal/store/pg/teams.go` | `PGTeamStore`: teams, tasks (atomic claim), messages, delegation history, handoff routes | +| `internal/store/pg/teams.go` | `PGTeamStore`: teams, tasks (atomic claim), messages, delegation history | | `internal/store/pg/memory_docs.go` | `PGMemoryStore`: document CRUD, indexing, chunking | | `internal/store/pg/memory_search.go` | Hybrid search: FTS, vector, ILIKE fallback, merge | | `internal/store/pg/skills.go` | `PGSkillStore`: skill CRUD and grants | diff --git a/docs/07-bootstrap-skills-memory.md b/docs/07-bootstrap-skills-memory.md index f74cd9d16..06e438d12 100644 --- a/docs/07-bootstrap-skills-memory.md +++ b/docs/07-bootstrap-skills-memory.md @@ -11,21 +11,50 @@ Three foundational systems that shape each agent's personality (Bootstrap), know --- -## 1. Bootstrap Files -- 7 Template Files +## 1. Bootstrap Files -- 13 Files (6 Template + 3 Virtual + 4 Memory Variants) -Markdown files loaded at agent initialization and embedded into the system prompt. MEMORY.md is NOT a bootstrap template file; it is a separate memory document loaded independently. +Bootstrap files are loaded at agent initialization and embedded into the system prompt. The system distinguishes between **stored template files** (with embedded defaults), **virtual system-injected files** (not stored on disk), and **memory files** (loaded separately from bootstrap). -| # | File | Role | Full Session | Subagent/Cron | -|---|------|------|:---:|:---:| -| 1 | AGENTS.md | Operating instructions, memory rules, safety guidelines | Yes | Yes | -| 2 | SOUL.md | Persona, tone of voice, boundaries | Yes | No | -| 3 | TOOLS.md | Local tool notes (camera, SSH, TTS, etc.) | Yes | Yes | -| 4 | IDENTITY.md | Agent name, creature, vibe, emoji | Yes | No | -| 5 | USER.md | User profile (name, timezone, preferences) | Yes | No | -| 6 | BOOTSTRAP.md | First-run ritual (deleted after completion) | Yes | No | +### Stored Template Files (6 files) + +Markdown files with embedded templates in `internal/bootstrap/templates/`. These are seeded on agent/user creation and can be customized. + +| # | File | Role | Full Session | Subagent/Cron | Agent Level | Per-User | +|---|------|------|:---:|:---:|:---:|:---:| +| 1 | AGENTS.md | Operating instructions, memory rules, safety guidelines | Yes | Yes | predefined | both | +| 2 | SOUL.md | Persona, tone of voice, boundaries | Yes | No | predefined | open only | +| 3 | TOOLS.md | Local tool notes (camera, SSH, TTS, etc.) | Yes | Yes | predefined | open only | +| 4 | IDENTITY.md | Agent name, creature, vibe, emoji | Yes | No | predefined | open only | +| 5 | USER.md | User profile (name, timezone, preferences) | Yes | No | — | both | +| 6 | BOOTSTRAP.md | First-run ritual (deleted after completion) | Yes | No | — | both | + +**Additional per-agent file:** +- USER_PREDEFINED.md (agent-level only): Baseline user-handling rules for predefined agents, shared across all users Subagent and cron sessions load only AGENTS.md + TOOLS.md (the `minimalAllowlist`). +### Virtual Context Files (3 files) + +System-injected files not stored on disk or in the database. Rendered in `` tags. + +| File | Condition | Content | Bootstrap Skip | +|------|-----------|---------|:---:| +| DELEGATION.md | Agent has agent links (manual delegation) | ≤15 targets: static list inline. >15 targets: description-only (no tool needed) | Yes | +| TEAM.md | Agent is a member of a team | Team name, role, teammate list with descriptions | Yes | +| AVAILABILITY.md | Always present (in negative contexts) | Agent availability status and scope limitations | Yes | + +Virtual files skip during first-run bootstrap to avoid wasting tokens when the agent should focus on onboarding. + +### Memory Files (4 file variants) + +NOT part of bootstrap template loading. Loaded separately by the memory system. + +| File | Role | Storage | Search | +|------|------|---------|--------| +| MEMORY.md | Curated memory (Markdown) | Per-agent + per-user | FTS + vector | +| memory.md | Fallback name for MEMORY.md | Checked if MEMORY.md missing | FTS + vector | +| MEMORY.json | Machine-readable memory index | Deprecated | — | + --- ## 2. Truncation Pipeline @@ -59,23 +88,25 @@ When a file is truncated, a marker is inserted between the head and tail section ## 3. Seeding -- Template Creation -Templates are embedded in the binary via Go `embed` (directory: `internal/bootstrap/templates/`). Seeding automatically creates default files for new users. +Templates are embedded in the binary via Go `embed` (directory: `internal/bootstrap/templates/`). Seeding automatically creates default files at agent creation (agent-level) and first-chat (per-user). ```mermaid flowchart TD - subgraph "Agent Level" - SB["SeedToStore()"] --> SB1{"Agent type = open?"} - SB1 -->|Yes| SKIP_AGENT["Skip (open agents use per-user only)"] - SB1 -->|No| SB2["Seed 6 files to agent_context_files
(all except BOOTSTRAP.md)"] - SB2 --> SB3{"File already has content?"} - SB3 -->|Yes| SKIP2["Skip"] - SB3 -->|No| WRITE2["Write embedded template"] + subgraph "Agent Level (SeedToStore)" + SB["New agent created"] --> SB1{"Agent type = open?"} + SB1 -->|Yes| SKIP_AGENT["Skip agent-level files
(open agents use per-user only)"] + SB1 -->|No| SB2["predefined agent"] + SB2 --> SB3["Seed to agent_context_files:
AGENTS.md, SOUL.md, IDENTITY.md,
USER_PREDEFINED.md"] + SB3 --> SB4["(skip USER.md, TOOLS.md,
BOOTSTRAP.md)"] + SB4 --> SB5{"File already has content?"} + SB5 -->|Yes| SKIP2["Skip"] + SB5 -->|No| WRITE2["Write embedded template"] end - subgraph "Per-User" - MC["SeedUserFiles()"] --> MC1{"Agent type?"} - MC1 -->|open| OPEN["Seed all 7 files to user_context_files"] - MC1 -->|predefined| PRED["Seed USER.md + BOOTSTRAP.md to user_context_files"] + subgraph "Per-User (SeedUserFiles)" + MC["First chat for user"] --> MC1{"Agent type?"} + MC1 -->|open| OPEN["Seed all 6 files:
AGENTS.md, SOUL.md, TOOLS.md,
IDENTITY.md, USER.md, BOOTSTRAP.md"] + MC1 -->|predefined| PRED["Seed 2 files:
USER.md (with agent fallback),
BOOTSTRAP.md (predefined template)"] OPEN --> CHECK{"File already has content?"} PRED --> CHECK CHECK -->|Yes| SKIP3["Skip -- never overwrite"] @@ -83,11 +114,11 @@ flowchart TD end ``` -`SeedUserFiles()` is idempotent -- safe to call multiple times without overwriting personalized content. +`SeedUserFiles()` is idempotent -- safe to call multiple times without overwriting personalized content. For predefined agents seeding USER.md, if the agent-level USER.md has content (e.g., configured by wizard/dashboard), that content is used as the per-user seed instead of the blank template, ensuring owner profiles propagate correctly. -### Predefined Agent Bootstrap +### Predefined Agent Bootstrap Ritual -`BOOTSTRAP.md` is seeded for predefined agents (per-user). On first chat, the agent runs the bootstrap ritual (learn name, preferences), then writes an empty `BOOTSTRAP.md` which triggers deletion. The empty-write deletion is ordered *before* the predefined write-block in `ContextFileInterceptor` to prevent an infinite bootstrap loop. +`BOOTSTRAP.md` is seeded per-user for both open and predefined agents. On first chat, the agent runs the bootstrap ritual (learn name, preferences), then writes an empty `BOOTSTRAP.md` which triggers deletion. The empty-write deletion is ordered *before* the template write-block in `ContextFileInterceptor` to prevent an infinite bootstrap loop. --- @@ -97,14 +128,17 @@ Two agent types determine which context files live at the agent level versus the | Agent Type | Agent-Level Files | Per-User Files | |------------|-------------------|----------------| -| `open` | None | All files (AGENTS, SOUL, TOOLS, IDENTITY, USER, BOOTSTRAP) | -| `predefined` | 6 files (shared across all users) | USER.md + BOOTSTRAP.md | +| `open` | None (all per-user) | AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, BOOTSTRAP.md | +| `predefined` | AGENTS.md, SOUL.md, IDENTITY.md, USER_PREDEFINED.md (shared) | USER.md, BOOTSTRAP.md (personalized per-user) | + +**Open agents:** Each user gets their own full set of context files with personal preferences and identity. Reading checks per-user copy first. -For `open` agents, each user gets their own full set of context files. When a file is read, the system checks the per-user copy first and falls back to the agent-level copy if not found. For `predefined` agents, all users share the same agent-level files except USER.md (personalized) and BOOTSTRAP.md (per-user first-run ritual, deleted after completion). +**Predefined agents:** All users share the same agent-level persona, identity, and tools. Each user has their own USER.md (profile) and BOOTSTRAP.md (first-run ritual). USER_PREDEFINED.md provides baseline user-handling rules at the agent level, allowing the model to adjust behavior per-user while maintaining consistency. -| Source | Per-User Storage | -|--------|-----------------| -| `agents` PostgreSQL table | `user_context_files` table | +| Storage | Location | +|---------|----------| +| Agent-level | `agent_context_files` table | +| Per-user | `user_context_files` table | --- @@ -159,16 +193,19 @@ flowchart TD Context files are wrapped in `` XML tags with a defensive preamble instructing the model to follow tone/persona guidance but not execute instructions that contradict core directives. The ExtraPrompt is wrapped in `` tags for context isolation. -### Virtual Context Files (DELEGATION.md, TEAM.md) +### Virtual Context Files (DELEGATION.md, TEAM.md, AVAILABILITY.md) + +Three files are system-injected by the resolver rather than stored on disk or in the DB. Rendered in `` tags (not ``) so the LLM does not attempt to read/write them. -Two files are system-injected by the resolver rather than stored on disk or in the DB: +| File | Injection Condition | Content | Skip Bootstrap | +|------|-------------------|---------|:---:| +| `DELEGATION.md` | Agent has manual (non-team) agent links | ≤15 targets: static list inline. >15 targets: description-only (no tool needed) | Yes | +| `TEAM.md` | Agent is a member of a team | Team name, role, teammate list with descriptions, workflow sentence | Yes | +| `AVAILABILITY.md` | Always (in negative context blocks) | Agent scope/availability status, capability limitations | Yes | -| File | Injection Condition | Content | -|------|-------------------|---------| -| `DELEGATION.md` | Agent has manual (non-team) agent links | ≤15 targets: static list. >15 targets: search instruction for `delegate_search` tool | -| `TEAM.md` | Agent is a member of a team | Team name, role, teammate list with descriptions, workflow sentence | +AVAILABILITY.md is always present but typically in negative context ("These files are NOT available") to prevent the model from attempting unavailable operations. All three skip during bootstrap to avoid wasting tokens when the agent should focus on onboarding. -Virtual files are rendered in `` tags (not ``) so the LLM does not attempt to read or write them as files. During bootstrap (first-run), both files are skipped to avoid wasting tokens when the agent should focus on onboarding. +When the model attempts `read_file` on a virtual file, `filesystem.go` returns a reminder message ("already loaded in system prompt") instead of attempting disk access. --- @@ -462,26 +499,41 @@ The flush is idempotent per compaction cycle -- it will not run again until the ## File Reference +### Bootstrap Files & Constants | File | Description | |------|-------------| -| `internal/bootstrap/files.go` | Bootstrap file constants, loading, session filtering | -| `internal/bootstrap/truncate.go` | Truncation pipeline (head/tail split, budget clamping) | -| `internal/bootstrap/seed_store.go` | Seeding: SeedToStore, SeedUserFiles | +| `internal/bootstrap/files.go` | File constants (AgentsFile, SoulFile, UserPredefinedFile, DelegationFile, TeamFile, AvailabilityFile, MemoryFile, etc.), loading, session filtering | +| `internal/bootstrap/seed.go` | Workspace bootstrap seeding (EnsureWorkspaceFiles, embedded template FS) | +| `internal/bootstrap/seed_store.go` | Store seeding (SeedToStore for agent-level, SeedUserFiles for per-user) | | `internal/bootstrap/load_store.go` | Load context files from DB (LoadFromStore) | -| `internal/bootstrap/templates/*.md` | Embedded template files | -| `internal/agent/systemprompt.go` | System prompt builder (BuildSystemPrompt, 17+ sections) | -| `internal/agent/systemprompt_sections.go` | Section renderers, virtual file handling (DELEGATION.md, TEAM.md) | -| `internal/agent/resolver.go` | Agent resolution, DELEGATION.md + TEAM.md injection | +| `internal/bootstrap/truncate.go` | Truncation pipeline (head/tail split, budget clamping) | +| `internal/bootstrap/templates/*.md` | Embedded template files: AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, USER_PREDEFINED.md, BOOTSTRAP.md, BOOTSTRAP_PREDEFINED.md | + +### System Prompt & Context Injection +| File | Description | +|------|-------------| +| `internal/agent/systemprompt.go` | System prompt builder (BuildSystemPrompt, PromptFull/PromptMinimal modes) | +| `internal/agent/systemprompt_sections.go` | Section renderers (17+ sections), virtual file handling (DELEGATION.md, TEAM.md, AVAILABILITY.md) | +| `internal/agent/resolver.go` | Agent resolution, virtual file injection, negative context blocks | | `internal/agent/loop_history.go` | Context file merging (base + per-user, base-only preserved) | | `internal/agent/memoryflush.go` | Memory flush logic (shouldRunMemoryFlush, runMemoryFlush) | | `internal/http/summoner.go` | Agent summoning -- LLM-powered context file generation | -| `internal/skills/loader.go` | Skill loader (5-tier hierarchy, BuildSummary, filtering) | +| `internal/tools/filesystem.go` | File access interception (write_file, read_file), virtual file reminder handling | + +### Skills System +| File | Description | +|------|-------------| +| `internal/skills/loader.go` | Skill loader (5-tier hierarchy, BuildSummary, inline/search mode decision) | | `internal/skills/search.go` | BM25 search index (tokenization, IDF scoring) | -| `internal/skills/watcher.go` | fsnotify watcher (500ms debounce, version bumping) | -| `internal/store/pg/skills.go` | Managed skill store (embedding search, backfill) | -| `internal/store/pg/skills_grants.go` | Skill grants (agent/user visibility, version pinning) | -| `internal/store/pg/memory_docs.go` | Memory document store (chunking, indexing, embedding) | -| `internal/store/pg/memory_search.go` | Hybrid search (FTS + vector merge, weighted scoring) | +| `internal/skills/watcher.go` | fsnotify watcher (500ms debounce, hot-reload, version bumping) | +| `internal/store/pg/skills.go` | Managed skill store (embedding search, auto-backfill) | +| `internal/store/pg/skills_grants.go` | Skill grants (agent/user visibility, version pinning, RBAC) | + +### Memory System +| File | Description | +|------|-------------| +| `internal/store/pg/memory_docs.go` | Memory document store (chunking, indexing, embedding, scoping) | +| `internal/store/pg/memory_search.go` | Hybrid search (FTS + vector merge, weighted scoring, scope filtering) | --- diff --git a/docs/08-scheduling-cron.md b/docs/08-scheduling-cron.md index 16b8b5e78..dd8a0b03f 100644 --- a/docs/08-scheduling-cron.md +++ b/docs/08-scheduling-cron.md @@ -62,16 +62,20 @@ flowchart TD ## 2. Session Queue -Each session key gets a dedicated queue that manages agent runs. The queue supports configurable concurrent runs per session. +Each session key gets a dedicated queue that manages agent runs. The queue supports configurable concurrent runs per session and adaptive throttling. ### Concurrent Runs +The scheduler configuration defines a default `MaxConcurrent` value (typically 1 for serial execution). Per-request overrides are available via `ScheduleWithOpts()`: + | Context | `maxConcurrent` | Rationale | |---------|:--------------:|-----------| | DMs | 1 | Single-threaded per user (no interleaving) | -| Groups | 3 | Multiple users can get responses in parallel | +| Groups | 3+ | Multiple users can get responses in parallel | + +Application code (not the scheduler) decides whether to override based on channel type. -**Adaptive throttle**: When session history exceeds 60% of the context window, concurrency drops to 1 to prevent context window overflow. +**Adaptive throttle**: When session history exceeds 60% of the context window, concurrency automatically drops to 1 to prevent context window overflow. Controlled by optional `TokenEstimateFunc` callback set on the scheduler. ### Queue Modes @@ -113,14 +117,28 @@ Cancel commands for Telegram and other channels. ### Implementation Details - **Debouncer bypass**: `/stop` and `/stopall` are intercepted before the 800ms debouncer to avoid being merged with the next user message -- **Cancel mechanism**: `SessionQueue.Cancel()` exposes the `CancelFunc` from the scheduler. Context cancellation propagates to the agent loop +- **Cancel mechanism**: `SessionQueue.CancelOne()` (for `/stop`) and `SessionQueue.CancelAll()` (for `/stopall`) expose the cancel functions. Context cancellation propagates to the agent loop +- **Stale message skipping**: `/stopall` sets an abort cutoff timestamp. Messages enqueued before the cutoff are skipped on next scheduling, preventing old messages from running after an abort - **Empty outbound**: On cancel, an empty outbound message is published to trigger cleanup (stop typing indicator, clear reactions) - **Trace finalization**: When `ctx.Err() != nil`, trace finalization falls back to `context.Background()` for the final DB write. Status is set to `"cancelled"` - **Context survival**: Context values (traceID, collector) survive cancellation -- only the Done channel fires +- **Generation counter**: Each `SessionQueue` tracks a generation counter. When reset (e.g., during SIGUSR1 in-process restart), old generations are ignored, preventing stale completions from interfering with new requests + +--- + +## 4. Adaptive Concurrency Control + +The scheduler can automatically reduce concurrency based on token usage. When a session's context history approaches the summary threshold (60% of context window), the effective `MaxConcurrent` is reduced to 1, enforcing serial execution to prevent overflow. + +**Implementation:** +- Set via `Scheduler.SetTokenEstimateFunc(fn TokenEstimateFunc)` +- `TokenEstimateFunc` returns `(tokens int, contextWindow int)` for a session +- Checked in `SessionQueue.effectiveMaxConcurrent()` before starting new runs +- Does not affect already-running tasks, only gates new task starts --- -## 4. Cron Lifecycle +## 5. Cron Lifecycle Scheduled tasks that run agent turns automatically. The run loop checks every second for due jobs. @@ -133,11 +151,13 @@ stateDiagram-v2 DueCheck --> Executing: nextRunAtMS <= now Executing --> Completed: Success Executing --> Failed: Failure - Failed --> Retrying: retry < MaxRetries - Retrying --> Executing: Backoff delay + Failed --> Retrying: retry < MaxRetries (0-3) + Retrying --> Executing: Backoff delay (2s to 30s) Failed --> ErrorLogged: Retries exhausted Completed --> Scheduled: Compute next nextRunAtMS (every/cron) Completed --> Deleted: deleteAfterRun (at jobs) + Scheduled --> Paused: Paused via EnableJob(false) + Paused --> Scheduled: Re-enabled via EnableJob(true) ``` ### Schedule Types @@ -150,33 +170,60 @@ stateDiagram-v2 ### Job States -Jobs can be `active` or `paused`. Paused jobs skip execution during the due check. Run results are logged to the `cron_run_logs` table. Cache invalidation propagates via the message bus. +Jobs have an `Enabled` boolean flag. When `false`, the job is skipped during the due-job check. When re-enabled, the next run is recomputed. Run results are logged in-memory (last 200 entries) and persisted to the PostgreSQL `cron_run_logs` table. Job state changes propagate via the message bus cache invalidation (`cache:cron` event). ### Retry -- Exponential Backoff with Jitter +When a cron job execution fails, it's automatically retried with exponential backoff before being logged as an error. + | Parameter | Default | |-----------|---------| | MaxRetries | 3 | | BaseDelay | 2 seconds | | MaxDelay | 30 seconds | -**Formula**: `delay = min(base x 2^attempt, max) +/- 25% jitter` +**Formula**: `delay = min(base × 2^attempt, max) ± 25% jitter` + +Example retry sequence: fail → wait 2s → retry → fail → wait 4s → retry → fail → wait 8s → retry → fail → wait 16s → stop. + +Retries are transparent to the user; final run status (ok or error) is logged to the `cron_run_logs` table. --- ## File Reference +### Scheduler (Lane-Based Concurrency) | File | Description | |------|-------------| | `internal/scheduler/lanes.go` | Lane and LaneManager (semaphore-based worker pools) | -| `internal/scheduler/queue.go` | SessionQueue, Scheduler, drop policies, debounce | -| `internal/cron/service.go` | Cron run loop, schedule parsing, job lifecycle | -| `internal/cron/retry.go` | Retry with exponential backoff + jitter | +| `internal/scheduler/queue.go` | SessionQueue, Scheduler, drop policies, debounce, cancel mechanics | +| `internal/scheduler/scheduler.go` | Scheduler top-level API, draining mode for graceful shutdown | +| `internal/scheduler/errors.go` | Error types: ErrQueueFull, ErrQueueDropped, ErrMessageStale, ErrGatewayDraining, ErrLaneCleared | + +### Cron Service (In-Memory) +| File | Description | +|------|-------------| +| `internal/cron/service.go` | Cron service lifecycle (start/stop), job CRUD | +| `internal/cron/service_execution.go` | Run loop (every 1s), job execution, schedule parsing, persistence | +| `internal/cron/retry.go` | Retry with exponential backoff + jitter, output truncation | +| `internal/cron/types.go` | Job, Schedule, JobState, RunLogEntry types | + +### Cron Persistence (PostgreSQL) +| File | Description | +|------|-------------| | `internal/store/cron_store.go` | CronStore interface (jobs + run logs) | -| `internal/store/pg/cron.go` | PostgreSQL cron implementation | +| `internal/store/pg/cron.go` | PostgreSQL cron operations (create, list, update, delete) | +| `internal/store/pg/cron_crud.go` | CRUD helpers for job mutations | | `internal/store/pg/cron_scheduler.go` | PG job cache, due-job detection, execution | +| `internal/store/pg/cron_exec.go` | Execution flow and result recording | +| `internal/store/pg/cron_scan.go` | Row scanning for jobs and run logs | +| `internal/store/pg/cron_update.go` | Job state updates in PostgreSQL | + +### Gateway Integration +| File | Description | +|------|-------------| | `cmd/gateway_cron.go` | makeCronJobHandler (routes cron execution to scheduler) | -| `cmd/gateway_agents.go` | Agent initialization | +| `cmd/gateway_agents.go` | Agent initialization and run loop setup | | `internal/gateway/methods/cron.go` | RPC method handlers (list, create, update, delete, toggle, run, runs) | --- diff --git a/docs/09-security.md b/docs/09-security.md index 31778ddd0..64d1a9521 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -31,14 +31,14 @@ flowchart TD The input guard scans for 6 injection patterns. -| Pattern | Detection Target | -|---------|-----------------| -| `ignore_instructions` | "ignore all previous instructions" | -| `role_override` | "you are now...", "pretend you are..." | -| `system_tags` | ``, `[SYSTEM]`, `[INST]`, `<>` | -| `instruction_injection` | "new instructions:", "override:", "system prompt:" | -| `null_bytes` | Null characters `\x00` (obfuscation attempts) | -| `delimiter_escape` | "end of system", ``, `` | +| Pattern | Detection Target | Regex Match | +|---------|-----------------|--------------| +| `ignore_instructions` | "ignore all previous instructions" | Case-insensitive: ignore + (all?)previous/prior/above/earlier/preceding + instructions/rules/prompts/directives/guidelines | +| `role_override` | "you are now...", "pretend you are..." | Case-insensitive: (you are now\|from now on you are\|pretend you are\|act as if you are\|imagine you are) | +| `system_tags` | ``, `[SYSTEM]`, `[INST]`, `<>` | Case-insensitive: ``, `[SYSTEM]`, `[INST]`, `<>`, `<\|im_start\|>system` | +| `instruction_injection` | "new instructions:", "override:", "system prompt:" | Case-insensitive: (new instructions?\|override\|system prompt\|<\|system\|>) | +| `null_bytes` | Null characters `\x00` (obfuscation attempts) | Raw `\x00` byte detection | +| `delimiter_escape` | "end of system", "begin user input", ``, `` | Case-insensitive: (end of system\|begin user input\|``\|``\|``\|``) | **Configurable action** (`gateway.injection_action`): @@ -53,7 +53,7 @@ The input guard scans for 6 injection patterns. ### Layer 3: Tool Security -**Shell deny patterns** -- 7 categories of blocked commands: +**Shell deny patterns** -- 7 categories of blocked commands (see `internal/tools/shell.go`): | Category | Examples | |----------|----------| @@ -65,6 +65,8 @@ The input guard scans for 6 injection patterns. | Reverse shells | `/dev/tcp/`, `nc -e` | | Eval injection | `eval $()`, `base64 -d \| sh` | +Commands are scanned at execution time via regex deny lists. Patterns can be configured per-binary via `exec_settings.deny_patterns` (default set hardened for destructive/exfil operations). Verbose flag blocking (deny_verbose list) prevents leakage of sensitive output. + **SSRF protection** -- 3-step validation: ```mermaid @@ -113,8 +115,9 @@ All four filesystem tools (`read_file`, `write_file`, `list_files`, `edit`) impl | Mechanism | Detail | |-----------|--------| -| Credential scrubbing | Static regex detection of: OpenAI (`sk-...`), Anthropic (`sk-ant-...`), GitHub (`ghp_/gho_/ghu_/ghs_/ghr_`), AWS (`AKIA...`), generic key-value patterns, connection strings (`postgres://`, `mysql://`), env var patterns (`KEY=`, `SECRET=`, `DSN=`), long hex strings (64+ chars). All replaced with `[REDACTED]`. | -| Dynamic credential scrubbing | Runtime-registered values (e.g., server IPs) scrubbed alongside static patterns via `AddDynamicScrubValues()` | +| Static credential scrubbing | Regex patterns detect: OpenAI (`sk-[a-zA-Z0-9]{20,}`), Anthropic (`sk-ant-[a-zA-Z0-9-]{20,}`), GitHub tokens (`ghp_/gho_/ghu_/ghs_/ghr_` + 36 chars), AWS (`AKIA[A-Z0-9]{16}`), generic patterns (API/token/secret/password/bearer/authorization + 8+ chars), connection strings (postgres/mysql/mongodb/redis/amqp URLs), env vars (KEY/SECRET/CREDENTIAL/PRIVATE + 8+ chars, DSN/DATABASE_URL/REDIS_URL/MONGO_URI), VIRTUAL_* vars (4+ chars), long hex strings (64+ chars). All replaced with `[REDACTED]`. | +| Dynamic credential scrubbing | Runtime-registered credential values (min 6 chars) scrubbed via `AddCredentialScrubValues()` and replaced with `[REDACTED]` | +| Dynamic value scrubbing (SSRF) | Server IPs and other runtime-discovered values registered via `AddDynamicScrubValues()` and replaced with `[SERVER_IP]` | | Web content wrapping | Fetched content wrapped in `<<>>` tags with security warning | ### Layer 5: Isolation @@ -283,7 +286,21 @@ flowchart TD --- -## 6. Security Logging Convention +## 6. API Key Security + +API keys are generated and stored securely. + +| Mechanism | Detail | +|-----------|--------| +| Format | `goclaw_<32 hex chars>` (48 chars total) | +| Key generation | 16 random bytes → hex-encoded, generated via `crypto.GenerateAPIKey()` | +| Storage | SHA-256 hash stored in database (`api_keys.hash`), never the raw key. Raw key shown once at creation. | +| Comparison | Timing-safe comparison via `crypto/subtle.ConstantTimeCompare` (not standard `==`) prevents timing attacks. Display prefix: first 8 hex chars of random part (e.g., `1a2b3c4d...`) | +| API auth | HTTP header `Authorization: Bearer {token}` or WebSocket param. Validated via constant-time hash comparison. | + +--- + +## 7. Security Logging Convention All security events use `slog.Warn` with a `security.*` prefix for consistent filtering and alerting. @@ -299,7 +316,7 @@ Filter all security events by grepping for the `security.` prefix in log output. --- -## 7. Hook Recursion Prevention +## 8. Hook Recursion Prevention The hook system (quality gates) can trigger infinite recursion: an agent evaluator delegates to a reviewer → delegation completes → fires quality gate → delegates to reviewer again → infinite loop. @@ -315,7 +332,7 @@ A context flag `hooks.WithSkipHooks(ctx, true)` prevents this. Three injection p --- -## 8. Group File Writer Restrictions +## 9. Group File Writer Restrictions In group chats (Telegram), write-sensitive operations are restricted to designated writers. This prevents unauthorized users from modifying agent files or resetting sessions in shared groups. @@ -347,15 +364,28 @@ Writers are managed via `/addwriter` (reply to a user's message) and `/removewri --- -## 9. Delegation Security +## 10. Browser Pairing Security + +Browser pairing allows web UI clients to authenticate without full admin credentials. + +| Mechanism | Detail | +|-----------|--------| +| Pairing code | 8-character alphanumeric code (A-Z, 2-9, excludes I/O/L for clarity), generated via `generatePairingCode()` in `internal/store/pg/pairing.go` | +| Code TTL | 60 minutes; expired codes are auto-pruned from database | +| Paired device TTL | 30 days; provides defense-in-depth expiry (paired devices auto-cleaned if unused) | +| Pending limit | Max 3 pending pairing requests per account; prevents spam/enumeration | +| HTTP access | Paired browsers access HTTP APIs via `X-GoClaw-Sender-Id` header (requires `channel=browser`). Fail-closed: `IsPaired()` check blocks unpaired sessions. Logs failed HTTP pairing auth attempts for security monitoring. | +| Approval flow | Requires WebSocket `device.pair.approve` method from authenticated admin session, triggered by `pairing.approve` command. Admin approval adds sender to `paired_devices` table with `paired_by` audit field. | +| Stale session fix | Uses `useRef` (not `useState`) for senderID in browser pairing form to prevent stale closure. Auto-kick after pairing: `RequireAuth` now accepts senderID for paired browser sessions (skips logout). | + +--- + +## 11. Delegation Security -Agent delegation uses directed permissions via the `agent_links` table. +Agent delegation is protected through delegation history tracking and concurrency controls. | Control | Scope | Description | |---------|-------|-------------| -| Directed links | A → B | A single row `(A→B, outbound)` means A can delegate to B, not the reverse | -| Per-user deny/allow | Per-link | `settings` JSONB on each link holds per-user restrictions (premium users only, blocked accounts) | -| Per-link concurrency | A → B | `agent_links.max_concurrent` limits simultaneous delegations from A to B | | Per-agent load cap | B (all sources) | `other_config.max_delegation_load` limits total concurrent delegations targeting B | When concurrency limits are hit, the error message is written for LLM reasoning: *"Agent at capacity (5/5). Try a different agent or handle it yourself."* @@ -367,18 +397,24 @@ When concurrency limits are hit, the error message is written for LLM reasoning: | File | Description | |------|-------------| | `internal/agent/input_guard.go` | Injection pattern detection (6 patterns) | -| `internal/tools/scrub.go` | Credential scrubbing (regex-based redaction) | +| `internal/tools/scrub.go` | Credential scrubbing (regex-based redaction), dynamic scrub values | | `internal/tools/shell.go` | Shell deny patterns, command validation | | `internal/tools/web_fetch.go` | Web content wrapping, SSRF protection | -| `internal/permissions/policy.go` | RBAC (3 roles, scope-based access) | -| `internal/gateway/ratelimit.go` | Gateway-level token bucket rate limiter | -| `internal/sandbox/` | Docker sandbox manager, FsBridge | +| `internal/permissions/policy.go` | RBAC (3 roles, scope-based access), method routing | +| `internal/gateway/ratelimit.go` | Gateway-level token bucket rate limiter (per user/IP) | +| `internal/sandbox/sandbox.go` | Docker sandbox configuration and modes | +| `internal/sandbox/docker.go` | Docker sandbox creation, execution, pruning | +| `internal/sandbox/fsbridge.go` | File operations in sandbox (read/write/list) | | `internal/crypto/aes.go` | AES-256-GCM encrypt/decrypt | +| `internal/crypto/apikey.go` | API key generation (format, hash, display prefix) | | `internal/tools/types.go` | PathDenyable interface definition | | `internal/tools/filesystem.go` | Denied path checking (`checkDeniedPath` helper) | | `internal/tools/filesystem_list.go` | Denied path support + directory filtering | | `internal/hooks/context.go` | WithSkipHooks / SkipHooksFromContext (recursion prevention) | | `internal/hooks/engine.go` | Hook engine, evaluator registry | +| `internal/gateway/methods/pairing.go` | Pairing RPC methods (request, approve, deny, list, revoke) | +| `internal/store/pg/pairing.go` | Pairing store implementation (code generation, TTLs) | +| `internal/store/pairing_store.go` | Pairing store interface definition | --- @@ -388,7 +424,7 @@ When concurrency limits are hit, the error message is written for LLM reasoning: |----------|-----------------| | [03-tools-system.md](./03-tools-system.md) | Shell deny patterns, exec approval, PathDenyable, delegation system, quality gates | | [04-gateway-protocol.md](./04-gateway-protocol.md) | WebSocket auth, RBAC, rate limiting | -| [06-store-data-model.md](./06-store-data-model.md) | API key encryption, agent access control pipeline, agent_links table | +| [06-store-data-model.md](./06-store-data-model.md) | API key encryption, agent access control pipeline | | [07-bootstrap-skills-memory.md](./07-bootstrap-skills-memory.md) | Context file merging, virtual files | | [08-scheduling-cron.md](./08-scheduling-cron.md) | Scheduler lanes, cron lifecycle, /stop and /stopall | | [10-tracing-observability.md](./10-tracing-observability.md) | Tracing and OTel export | diff --git a/docs/10-tracing-observability.md b/docs/10-tracing-observability.md index b4301e12c..dfb4c65be 100644 --- a/docs/10-tracing-observability.md +++ b/docs/10-tracing-observability.md @@ -67,9 +67,15 @@ Token counts are aggregated **only from `llm_call` spans** (not `agent` spans) t | Mode | InputPreview | OutputPreview | |------|:---:|:---:| | Normal | Not recorded | 500 characters max | -| Verbose (`GOCLAW_TRACE_VERBOSE=1`) | Up to 50KB | 500 characters max | +| Verbose (`GOCLAW_TRACE_VERBOSE=1`) | Up to 200KB | Up to 200KB | -Verbose mode is useful for debugging LLM conversations. Full input messages (including system prompt, history, and tool results) are serialized as JSON and stored in the span's `InputPreview` field, truncated at 50,000 characters. +Verbose mode is useful for debugging LLM conversations. When enabled via `GOCLAW_TRACE_VERBOSE=1`: + +- **LLM spans**: Full input messages (including system prompt, history, and tool results) are serialized as JSON and stored in `InputPreview` (truncated at 200KB). LLM response content is stored in `OutputPreview` (truncated at 200KB, includes `` tag if present). +- **Tool spans**: Tool input and output are both recorded up to 200KB. +- **Agent span**: Input message and output are both recorded up to 200KB. + +In normal mode, previews are truncated to 500 characters max to minimize storage overhead. --- @@ -106,7 +112,70 @@ The exporter lives in a separate sub-package (`internal/tracing/otelexport/`) so --- -## 5. Trace HTTP API +## 5. Cost Calculation + +Per-span cost is calculated using the `CalculateCost()` function in `internal/tracing/cost.go`. For each LLM call span: + +``` +Cost = (PromptTokens × InputCostPerMillion) / 1,000,000 + + (CompletionTokens × OutputCostPerMillion) / 1,000,000 + + (CacheReadTokens × CacheReadCostPerMillion) / 1,000,000 + + (CacheCreationTokens × CacheCreateCostPerMillion) / 1,000,000 +``` + +Model pricing is loaded from `config.ModelPricing` and keyed by `provider/model` (with fallback to `model` only). Cost is stored in the `total_cost` field of each LLM call span. The trace aggregation sums costs from all child `llm_call` spans to compute the trace-level `total_cost`. + +Cache token costs (read + create) are optional and only applied if the pricing config specifies non-zero values. + +--- + +## 6. Snapshot Worker -- Realtime Usage Aggregation + +The `SnapshotWorker` periodically aggregates trace and span data into hourly `usage_snapshots` for realtime analytics and dashboard displays. + +### Operation + +- **Schedule**: Ticks every hour at HH:05:00 UTC (5 minutes past the hour) +- **Catch-up**: On startup and after each tick, computes snapshots for all missed hours +- **Backfill**: `Backfill()` method populates historical snapshots from the earliest trace to now + +### Snapshot Dimensions + +For each hour `[00:00, 01:00)`, the worker creates two types of snapshot rows: + +1. **Totals Row** (`provider=""`, `model=""`) — Aggregated from traces: + - `request_count` — Count of root traces + - `error_count` — Count of failed traces + - `unique_users` — Distinct `user_id` in traces + - `input_tokens`, `output_tokens` — Sum from all child `llm_call` spans + - `total_cost` — Sum of costs from all child `llm_call` spans + - `tool_call_count` — Sum from traces + - `avg_duration_ms` — Average trace duration + - `memory_docs`, `memory_chunks` — Point-in-time count (attached to agent's totals row only) + - `kg_entities`, `kg_relations` — Point-in-time count (attached to agent's totals row only) + +2. **Detail Rows** (`provider` + `model` specified) — Aggregated from `llm_call` spans: + - `llm_call_count` — Count of LLM calls for this provider/model + - `input_tokens`, `output_tokens` — Sum of tokens + - `total_cost` — Sum of per-call costs + - `cache_read_tokens`, `cache_create_tokens`, `thinking_tokens` — Sum from span metadata + +Grouping: by `(agent_id, channel)` for totals; by `(agent_id, channel, provider, model)` for details. + +### Usage + +```go +worker := tracing.NewSnapshotWorker(db, snapshotStore) +worker.Start() + +// Later: +hoursBackfilled, err := worker.Backfill(ctx) +worker.Stop() +``` + +--- + +## 7. Trace HTTP API | Method | Path | Description | |--------|------|-------------| @@ -126,7 +195,7 @@ The exporter lives in a separate sub-package (`internal/tracing/otelexport/`) so --- -## 6. Delegation History +## 8. Delegation History Delegation history records are stored in the `delegation_history` table and exposed alongside traces for cross-referencing agent interactions. @@ -144,10 +213,12 @@ Delegation history is automatically recorded by `DelegateManager.saveDelegationH | File | Description | |------|-------------| -| `internal/tracing/collector.go` | Collector buffer-flush, EmitSpan, FinishTrace | -| `internal/tracing/context.go` | Trace context propagation (TraceID, ParentSpanID) | +| `internal/tracing/collector.go` | Collector buffer-flush, EmitSpan, FinishTrace, verbose mode | +| `internal/tracing/context.go` | Trace context propagation (TraceID, ParentSpanID, DelegateParentTraceID) | +| `internal/tracing/cost.go` | Cost calculation and pricing lookup | +| `internal/tracing/snapshot_worker.go` | Hourly usage aggregation into snapshots | | `internal/tracing/otelexport/exporter.go` | OTel OTLP exporter (gRPC + HTTP) | -| `internal/store/tracing_store.go` | TracingStore interface | +| `internal/store/tracing_store.go` | TracingStore interface, span/trace type constants | | `internal/store/pg/tracing.go` | PostgreSQL trace/span persistence + aggregation | | `internal/http/traces.go` | Trace HTTP API handler (GET /v1/traces) | | `internal/agent/loop_tracing.go` | Span emission from agent loop (LLM, tool, agent spans) | diff --git a/docs/11-agent-teams.md b/docs/11-agent-teams.md index 3c769525a..1e1212097 100644 --- a/docs/11-agent-teams.md +++ b/docs/11-agent-teams.md @@ -125,12 +125,16 @@ The task board is a shared work tracker accessible to all team members via the ` ```mermaid flowchart TD subgraph "Task Lifecycle" - PENDING["Pending
(just created)"] -->|claim| IN_PROGRESS["In Progress
(agent working)"] + PENDING["Pending
(just created)"] -->|claim or assign| IN_PROGRESS["In Progress
(agent working)"] PENDING -->|blocked_by set| BLOCKED["Blocked
(waiting on dependencies)"] BLOCKED -->|all blockers complete| PENDING - IN_PROGRESS -->|complete| COMPLETED["Completed
(with result)"] - PENDING -->|cancel| CANCELLED["Cancelled"] + IN_PROGRESS -->|review| IN_REVIEW["In Review
(pending approval)"] + IN_REVIEW -->|approve| COMPLETED["Completed
(with result)"] + IN_REVIEW -->|reject| CANCELLED["Cancelled
(auto-unblocks dependents)"] IN_PROGRESS -->|cancel| CANCELLED + PENDING -->|cancel| CANCELLED + PENDING -->|system failure| FAILED["Failed
(stale/error)"] + FAILED -->|retry| PENDING end ``` @@ -138,28 +142,161 @@ flowchart TD | Action | Description | Who Uses It | |--------|-------------|-------------| -| `create` | Create task with subject, description, priority, blocked_by | Lead | -| `claim` | Atomically claim a pending task | Members (or auto-claimed by delegation) | -| `complete` | Mark task done with a result summary | Members (or auto-completed by delegation) | -| `cancel` | Cancel a task with a reason | Lead | -| `list` | List tasks (filter: active/completed/all, order: priority/newest) | All | -| `get` | Get full task detail including result | All | +| `create` | Create task with subject, description, priority, assignee, blocked_by | Lead/Admin | +| `claim` | Atomically claim a pending task | Members | +| `complete` | Mark task done with result summary | Members/Agents | +| `approve` | Approve completed task (human-in-the-loop) | Admin/Human | +| `reject` | Reject task with reason, mark as cancelled, inject message to lead | Admin/Human | +| `cancel` | Cancel task with reason | Lead | +| `assign` | Admin-assign a pending task to an agent | Admin | +| `review` | Submit task for review, transitions to in_review status | Members | +| `comment` | Add comment to task | All | +| `progress` | Update task progress (percent, step) | Members | +| `list` | List tasks (filter: active/in_review/completed/all, page) | All | +| `get` | Get full task detail with comments, events, attachments | All | | `search` | Full-text search over subject + description | All | +| `attach` | Attach workspace file to task | Members | +| `ask_user` | Set periodic reminder sent to user for decision | Members | +| `clear_ask_user` | Cancel a previously set ask_user reminder | Members | +| `retry` | Re-dispatch stale or failed tasks back to pending | Admin | +| `update` | Update task metadata (priority, description, etc.) | Lead | + +### Team Versioning + +Many task actions require **team version >= 2**. Teams created with v1 only support basic actions. + +**V2-Required Actions:** +- `approve` — Approve completed task +- `reject` — Reject and cancel task +- `review` — Submit for review +- `comment` — Add comments +- `progress` — Update progress +- `attach` — Attach files +- `update` — Update metadata +- `ask_user` — Set reminders +- `clear_ask_user` — Cancel reminders +- `retry` — Retry failed tasks + +**V1 Teams** support only: `create`, `claim`, `complete`, `cancel`, `assign`, `list`, `get`, `search` + +If a v1 team tries a v2 action, error: `"action 'X' requires team version 2 — upgrade in team settings"` ### Atomic Claiming Two agents grabbing the same task is prevented at the database level. The claim operation uses a conditional update: `SET status = 'in_progress', owner = agent WHERE status = 'pending' AND owner IS NULL`. One row updated means claimed; zero rows means someone else got it first. No distributed mutex needed. -### Task Dependencies +### Task Dependencies & Blocking + +Tasks can declare `blocked_by` — a list of prerequisite task IDs. When a task has blocking dependencies: +- Task enters `blocked` status (distinct from `pending`) +- Task remains blocked until ALL prerequisites are completed +- When a blocking task completes, all dependent tasks with now-satisfied blockers automatically transition from `blocked` → `pending` +- Cancelled tasks (via `cancel` or `reject`) also unblock their dependents + +The `blocked` status is one of 8 possible statuses: `pending`, `in_progress`, `in_review`, `completed`, `failed`, `cancelled`, `blocked`, `stale`. + +### Task Data Model + +| Field | Description | +|-------|-------------| +| `id`, `team_id` | Unique ID + team ownership | +| `subject`, `description` | Task title and details | +| `status` | pending, in_progress, in_review, completed, failed, cancelled, blocked, stale | +| `priority` | Integer (higher = more important) | +| `owner_agent_id` | Agent currently working on task | +| `created_by_agent_id` | Agent that created the task (if auto-created by agent) | +| `blocked_by` | List of task IDs this task depends on | +| `task_type` | "general" or custom type label | +| `task_number` | Human-readable sequential number (team-local) | +| `progress_percent`, `progress_step` | Current progress tracking | +| `metadata` | Custom JSON for task snapshots, peer_kind, local_key, team_workspace | +| `user_id`, `chat_id`, `channel` | Scope: which user/group triggered this task | +| `result` | Result summary when completed | + +### Task Snapshots + +Completed tasks automatically store snapshots in metadata for UI board visualization: + +```json +{ + "snapshot": { + "completed_at": "2026-03-16T12:34:56Z", + "result_preview": "First 100 chars of result...", + "final_status": "completed", + "ai_summary": "Brief AI-generated summary of what was accomplished" + } +} +``` + +The board displays these snapshots in a visual timeline, allowing users to review completed work at a glance. + +### Delegate Agent Restrictions + +Delegate agents (members executing delegated work) have restrictions to prevent lifecycle corruption: + +- **Cannot complete directly**: Results are auto-completed when delegation finishes + - Error: `"delegate agents cannot complete team tasks directly — results are auto-completed when delegation finishes"` +- **Cannot cancel tasks**: Only the lead can cancel + - Error: `"delegate agents cannot cancel team tasks directly"` +- **Cannot approve/reject**: Only lead and dashboard users can approve/reject + - Error: `"delegate agents cannot reject team tasks"` (for reject) + +This ensures task state transitions are controlled and audit-trail integrity is maintained. + +### Assignee is Mandatory + +When creating a task via `team_tasks(action="create")`, the `assignee` field is **required**. This specifies which team member should handle the task. If omitted, error: `"assignee is required — specify which team member should handle this task"` + +### Concurrent Creation Guard + +Agents must list existing tasks before creating new ones. This prevents duplicate task creation in concurrent sessions. When an agent calls `create` without first checking the board: +- Error: `"You must check existing tasks first. Call team_tasks(action='list') to review the current task board before creating new tasks — this prevents duplicates in concurrent sessions."` + +### Auto-Claiming Behavior + +When an agent calls `complete` on a `pending` task, the task is **automatically claimed first**. This saves an extra tool call: +1. Agent calls `complete` on task in `pending` status +2. System atomically claims the task (pending → in_progress, assign to agent) +3. System marks as `completed` +4. Returns success in one action + +This is safe because the claim is atomic — only one agent can succeed. + +### User & Channel Scoping + +- **System/delegate channels**: See all tasks for the team +- **Regular user channels**: Filter to tasks they triggered (filtered by user ID) +- **Scope discovery**: `teams.scopes` lists all unique channel+chatID scopes across tasks +- **Known users**: `teams.known_users` lists distinct user IDs from team member sessions (UI user select) +- **Pagination**: 30 tasks per page for lists +- **Result truncation**: 8,000 characters for `get`, 500 characters for search snippets + +### Comments, Events & Attachments + +#### Task Comments + +Humans and agents can add comments to provide feedback or clarification: + +- `handleTaskComment` (human adds comment via dashboard) +- Comments stored with author ID (agent_id or user_id), creation timestamp +- Emits `EventTeamTaskCommented` event +- Visible in task detail page + +#### Task Events + +Audit trail of all task state changes: + +- Event types: `created`, `assigned`, `completed`, `approved`, `rejected`, `commented`, `failed`, `cancelled`, `stale`, `recovered` +- Each event records actor type (agent or human), actor ID, timestamp, and optional metadata +- Used for compliance audits and UI activity timeline -Tasks can declare `blocked_by` — a list of prerequisite task IDs. A blocked task stays in `blocked` status until all its prerequisites are completed. When a task is completed via `complete`, all dependent tasks whose blockers are now all done automatically transition from `blocked` to `pending`. +#### Task Attachments -### User Scoping +Workspace files can be attached to tasks: -- Delegate and system channels see all tasks for the team -- End users see only tasks they triggered (filtered by user ID) -- List pagination: 20 tasks per page -- Result truncation: 8,000 characters for `get`, 500 characters for search snippets +- Attach action links workspace file (by file ID) to task +- Auto-links files created during task execution +- Metadata captures which agent/user attached the file --- @@ -192,22 +329,74 @@ The receiving agent processes this as an inbound message, routed through the del --- -## 6. Delegation Integration +## 6. Team Workspace + +Each team has a shared workspace for storing files produced during task execution. Workspace scoping is configurable per team. + +### Workspace Modes + +| Mode | Directory Structure | Use Case | +|------|-------------------|----------| +| **Isolated** (default) | `{dataDir}/teams/{teamID}/{chatID}/` | Per-conversation file isolation; each user/chat has own folder | +| **Shared** | `{dataDir}/teams/{teamID}/` | All team members access same folder; no user/chat isolation | + +Configure via team settings `workspace_scope: "shared"` (default: `"isolated"`). + +### Workspace Access + +Team members have file tools access to their team workspace: + +- **Read**: List files, read file content +- **Write**: Create and update files (auto-linked to task) +- **Delete**: Remove files from workspace + +When a member writes a file during task execution, it's automatically: +1. Stored in team workspace with metadata +2. Linked to the active task (task_id) +3. Visible to other team members on task detail page + +### WorkspaceDir Context + +During task dispatch, the team workspace directory is injected into tool context: + +```go +WithToolTeamWorkspace(ctx, "/path/to/teams/{teamID}/") +WithToolTeamID(ctx, "{teamID}") +WithTeamTaskID(ctx, "{taskID}") +WithWorkspaceChannel(ctx, task.Channel) +WithWorkspaceChatID(ctx, task.ChatID) +``` + +File tools use this context to resolve workspace paths and auto-link files to tasks. + +### Quota & Limits + +| Limit | Value | +|-------|-------| +| Max file size | 10 MB | +| Max files per scope | 100 | +| Directory creation | Automatic (0750 permissions) | + +--- + +## 7. Delegation Integration Teams integrate deeply with the delegation system. The mandatory workflow ensures every piece of delegated work is tracked. ```mermaid flowchart TD LEAD["Lead receives user request"] --> CREATE["1. Create task on board
team_tasks action=create
→ returns task_id"] - CREATE --> SPAWN["2. Delegate to member
spawn agent=member, task=...,
team_task_id=task_id"] - SPAWN --> LANE["Scheduled through
delegate lane"] - LANE --> MEMBER["Member agent executes
in isolated session"] + CREATE --> SPAWN["2. Delegate to member
spawn agent=member,
team_task_id=task_id"] + SPAWN --> INJECT["Inject team workspace context
WithToolTeamID
WithToolTeamWorkspace
WithTeamTaskID"] + INJECT --> LANE["Scheduled through
delegate lane"] + LANE --> MEMBER["Member agent executes
in isolated session
with workspace access"] MEMBER --> COMPLETE["3. Task auto-completed
with delegation result"] - COMPLETE --> CLEANUP["Session cleaned up"] + COMPLETE --> DISPATCH["Files auto-linked to task
Comments/events recorded"] + DISPATCH --> CLEANUP["Session cleaned up"] subgraph "Parallel Delegation" - SPAWN2["spawn member_A, task_id=1"] --> RUN_A["Member A works"] - SPAWN3["spawn member_B, task_id=2"] --> RUN_B["Member B works"] + SPAWN2["spawn member_A, task_id=1"] --> RUN_A["Member A works
with workspace"] + SPAWN3["spawn member_B, task_id=2"] --> RUN_B["Member B works
with workspace"] RUN_A --> COLLECT["Results collected"] RUN_B --> COLLECT COLLECT --> ANNOUNCE["4. Single combined
announcement to lead"] @@ -228,9 +417,11 @@ When a team member delegates work, the system requires a valid `team_task_id`: When a delegation finishes (success or failure): 1. The linked task is marked as `completed` with the delegation result -2. A team message audit record is created (from member → lead) -3. The delegation session is cleaned up (deleted) -4. Delegation history is saved with team context (team_id, team_task_id, trace_id) +2. Files created during execution are auto-linked to the task +3. Workspace events are recorded (modified/created file events) +4. A team message audit record is created (from member → lead) +5. The delegation session is cleaned up (deleted) +6. Delegation history is saved with team context (team_id, team_task_id, trace_id) ### Parallel Delegation Batching @@ -255,7 +446,7 @@ If all delegations failed, the lead receives a friendly error notification with --- -## 7. TEAM.md — System-Injected Context +## 8. TEAM.md — System-Injected Context `TEAM.md` is a virtual file generated at agent resolution time. It is not stored on disk or in the database — it's rendered dynamically based on the current team configuration and injected into the system prompt wrapped in `` tags. @@ -298,7 +489,7 @@ This prevents wasted LLM iterations probing unavailable capabilities. --- -## 8. Message Routing +## 9. Message Routing Team messages flow through the message bus with specific routing rules. @@ -345,7 +536,7 @@ This ensures results land in the correct conversation thread, even in Telegram f --- -## 9. Access Control +## 10. Access Control Teams support fine-grained access control through team settings. @@ -353,10 +544,17 @@ Teams support fine-grained access control through team settings. | Setting | Type | Description | |---------|------|-------------| -| `AllowUserIDs` | String list | Only these users can trigger team work | -| `DenyUserIDs` | String list | These users are blocked (deny takes priority) | -| `AllowChannels` | String list | Only messages from these channels trigger team work | -| `DenyChannels` | String list | Block messages from these channels | +| `allow_user_ids` | String list | Only these users can trigger team work | +| `deny_user_ids` | String list | These users are blocked (deny takes priority) | +| `allow_channels` | String list | Only messages from these channels trigger team work | +| `deny_channels` | String list | Block messages from these channels | +| `workspace_scope` | String | "isolated" (default) or "shared" — file scope mode | +| `workspace_quota_mb` | Integer | Max workspace size in MB (optional) | +| `progress_notifications` | Boolean | Emit progress_notification events | +| `followup_interval_minutes` | Integer | Ask_user reminder interval | +| `followup_max_reminders` | Integer | Max ask_user reminders before escalation | +| `escalation_mode` | String | How to escalate stale tasks: "notify_lead", "fail_task" | +| `escalation_actions` | String list | Actions to take on escalation | System channels (`delegate`, `system`) always pass access checks. Empty settings mean open access. @@ -380,7 +578,7 @@ When limits are hit, the error message is written for LLM reasoning: "Agent at c --- -## 10. Delegation Context +## 11. Delegation Context ### SenderID Clearing @@ -390,20 +588,44 @@ In sync delegations, the delegate agent's context has the `senderID` cleared. Th Delegation traces are linked to the parent trace through `parent_trace_id`. This allows the tracing system to show the full delegation chain: user request → lead processing → member delegation → member execution. +### Workspace & Task Context + +Delegation context includes team workspace and task information so member agents can: + +1. Access the team workspace directory (if configured) +2. Auto-link files created to the active task +3. Record task progress and comments +4. Route results back to the correct user/chat + +Context keys injected: + +- `tool_team_id`: Team UUID for team_tasks/team_message tools +- `tool_team_workspace`: Shared workspace directory path (or empty for isolated mode) +- `tool_team_task_id`: Active task UUID for workspace file linking +- `tool_workspace_channel`: Task's origin channel (for routing) +- `tool_workspace_chat_id`: Task's origin chat ID (for routing) + --- -## 11. Events +## 12. Events Teams emit events for real-time UI updates and observability. | Event | When | |-------|------| +| `team_task.created` | New task added to board | +| `team_task.assigned` | Task assigned to agent (admin or auto-assign) | +| `team_task.completed` | Task marked as complete | +| `team_task.approved` | Task approved by human (human-in-the-loop) | +| `team_task.rejected` | Task rejected, returned to in_progress | +| `team_task.commented` | Comment added by human | +| `team_task.deleted` | Task hard-deleted (terminal status only) | +| `team_updated` | Team settings updated | +| `team_deleted` | Team deleted | | `delegation.started` | Async delegation begins | | `delegation.completed` | Delegation finishes successfully | | `delegation.failed` | Delegation fails | | `delegation.cancelled` | Delegation cancelled | -| `team_task.created` | New task added to board | -| `team_task.completed` | Task marked as complete | | `team_message.sent` | Mailbox message delivered | --- @@ -412,20 +634,25 @@ Teams emit events for real-time UI updates and observability. | File | Purpose | |------|---------| -| `internal/gateway/methods/teams.go` | Team CRUD RPC handlers, auto-link creation on team create/member add | +| `internal/gateway/methods/teams_crud.go` | Team CRUD RPC: Get, Delete, Update settings, TaskList, KnownUsers, Scopes, Events | +| `internal/gateway/methods/teams_tasks.go` | Task board RPC: Get, Create, Assign, Comment, Comments, Events, Approve, Reject, Delete, TaskDispatch | +| `internal/gateway/methods/teams_workspace.go` | Workspace RPC: List, Read, Delete (with shared/isolated mode logic) | | `internal/tools/team_tool_manager.go` | Shared backend for team tools, team cache (5-min TTL), team resolution | -| `internal/tools/team_tasks_tool.go` | Task board tool: list, get, create, claim, complete, cancel, search | +| `internal/tools/team_tasks_tool.go` | Task board tool: list, get, create, claim, complete, cancel, search, approve, reject, comment, progress, attach, ask_user, update | | `internal/tools/team_message_tool.go` | Mailbox tool: send, broadcast, read, message routing via bus | -| `internal/tools/delegate.go` | DelegateManager: sync/async delegation, team task enforcement, auto-completion | -| `internal/tools/delegate_state.go` | Active delegation tracking, artifact accumulation, session cleanup | -| `internal/tools/delegate_policy.go` | Access control: user permissions, team access, concurrency checks | -| `internal/tools/delegate_events.go` | Delegation event broadcasting | +| `internal/tools/team_access_policy.go` | Access control: checkTeamAccess validates user/channel against settings | +| `internal/tools/subagent_spawn_tool.go` | Subagent spawning: sync/async delegation, team task enforcement | +| `internal/tools/subagent_exec.go` | Delegation execution, artifact accumulation, session cleanup | +| `internal/tools/subagent_config.go` | Delegation configuration and concurrency control | +| `internal/tools/subagent_tracing.go` | Delegation tracing and event broadcasting | +| `internal/tools/workspace_dir.go` | WorkspaceDir helper, shared/isolated mode detection, file limits | +| `internal/tools/context_keys.go` | Tool context injection: team_id, team_workspace, team_task_id, workspace channel/chatid | | `internal/agent/resolver.go` | TEAM.md generation (buildTeamMD), injection during agent resolution | | `internal/agent/systemprompt_sections.go` | TEAM.md rendering in system prompt as `` | -| `internal/store/team_store.go` | TeamStore interface (22 methods), data types | -| `internal/store/pg/teams.go` | PostgreSQL implementation: teams, tasks, messages, handoff routes | +| `internal/store/team_store.go` | TeamStore interface (~40 methods), data types: TeamData, TeamTaskData, TeamMessageData, TeamTaskCommentData, etc. | +| `internal/store/pg/teams.go` | PostgreSQL implementation: teams CRUD, members, tasks, messages, events, attachments | | `cmd/gateway_managed.go` | Team tool wiring, cache invalidation subscription | -| `cmd/gateway_consumer.go` | Message routing for delegate/teammate prefixes | +| `cmd/gateway_consumer.go` | Message routing for delegate/teammate prefixes, task dispatch to agents | --- @@ -433,7 +660,7 @@ Teams emit events for real-time UI updates and observability. | Document | Relevant Content | |----------|-----------------| -| [03-tools-system.md](./03-tools-system.md) | Delegation system, agent links, quality gates, evaluate loop | -| [06-store-data-model.md](./06-store-data-model.md) | Team tables schema, delegation_history, handoff_routes | +| [03-tools-system.md](./03-tools-system.md) | Delegation system, agent links, quality gates | +| [06-store-data-model.md](./06-store-data-model.md) | Team tables schema, delegation_history | | [08-scheduling-cron.md](./08-scheduling-cron.md) | Delegate scheduler lane (concurrency 100), cron | | [09-security.md](./09-security.md) | Delegation security, hook recursion prevention | diff --git a/docs/12-extended-thinking.md b/docs/12-extended-thinking.md index 9aeea6771..d329eed1c 100644 --- a/docs/12-extended-thinking.md +++ b/docs/12-extended-thinking.md @@ -35,11 +35,13 @@ flowchart TD MAP -->|Anthropic| ANTH["Budget tokens: 10,000
Header: anthropic-beta
Strip temperature"] MAP -->|OpenAI-compat| OAI["Map to reasoning_effort
(low/medium/high)"] - MAP -->|DashScope| DASH["enable_thinking: true
Budget: 16,384 tokens
⚠ Disable streaming with tools"] + MAP -->|DashScope| DASH["enable_thinking: true
Budget: 16,384 tokens
⚠ Model-specific + tools limitation"] + MAP -->|Codex| CODEX["reasoning_tokens tracked
via Responses API"] ANTH --> SEND["Send to LLM"] OAI --> SEND DASH --> SEND + CODEX --> SEND ``` ### Anthropic (Native) @@ -75,8 +77,21 @@ Reasoning content is returned in the `reasoning_content` field of the response d Enables thinking via `enable_thinking: true` plus a `thinking_budget` parameter. +**Model-specific support**: Only certain Qwen3 models accept the `enable_thinking` / `thinking_budget` parameters: +- **Qwen3.5 series**: `qwen3.5-plus`, `qwen3.5-turbo` (thinking + vision) +- **Qwen3 hosted**: `qwen3-max` +- **Qwen3 open-weight**: `qwen3-235b-a22b`, `qwen3-32b`, `qwen3-14b`, `qwen3-8b` + +Other models (e.g., `qwen3-plus`, `qwen3-turbo`) silently skip thinking injection to avoid API errors. + **Important limitation**: DashScope does not support streaming when tools are present. When an agent has tools enabled and thinking is active, the provider automatically falls back to non-streaming mode (single `Chat()` call) and synthesizes chunk callbacks to maintain the event flow. +### Codex (Alibaba AI Reasoning) + +Codex natively supports extended reasoning through its Responses API. Thinking/reasoning tokens are streamed as discrete `reasoning` events with summary fragments. + +**Token tracking**: Reasoning token count is exposed in `response.completed` / `response.incomplete` events as `OutputTokensDetails.ReasoningTokens` and accessible via `ChatResponse.Usage.ThinkingTokens`. + --- ## 3. Streaming @@ -101,7 +116,8 @@ flowchart TD |----------|---------------|---------------| | Anthropic | `thinking_delta` in content blocks | `text_delta` in content blocks | | OpenAI-compat | `reasoning_content` in delta | `content` in delta | -| DashScope | No streaming with tools (falls back to non-streaming) | Same | +| DashScope | Same as OpenAI (when tools absent) | Same as OpenAI | +| Codex | `reasoning` items with text summaries | `content` items | ### Token Estimation @@ -141,7 +157,8 @@ OpenAI-compatible providers handle thinking/reasoning content as metadata. The ` | Provider | Limitation | |----------|-----------| -| DashScope | Cannot stream when tools are present — falls back to non-streaming mode | +| DashScope | Cannot stream when tools are present — falls back to non-streaming mode. Only specific Qwen3 models support thinking. | +| Codex | Reasoning tokens tracked via API response (not in streaming chunks themselves) | | Anthropic | Temperature parameter stripped when thinking is enabled | | All | Thinking tokens count against the context window budget | | All | Thinking increases latency and cost proportional to the budget level | @@ -152,12 +169,13 @@ OpenAI-compatible providers handle thinking/reasoning content as metadata. The ` | File | Purpose | |------|---------| -| `internal/providers/types.go` | ThinkingCapable interface, StreamChunk.Thinking field, OptThinkingLevel constant | -| `internal/providers/anthropic.go` | Anthropic thinking implementation: budget mapping, header injection, temperature stripping | -| `internal/providers/anthropic_stream.go` | Streaming: thinking_delta handling, RawAssistantContent accumulation | -| `internal/providers/anthropic_request.go` | Request building: thinking block preservation for tool loops | +| `internal/providers/types.go` | ThinkingCapable interface, StreamChunk.Thinking field, Opt* thinking constants | +| `internal/providers/anthropic.go` | Anthropic: budget mapping (4K/10K/32K), beta header injection, temperature stripping | +| `internal/providers/anthropic_stream.go` | Anthropic streaming: thinking_delta handling, raw block accumulation | +| `internal/providers/anthropic_request.go` | Anthropic request: thinking block preservation for tool loops | | `internal/providers/openai.go` | OpenAI-compat: reasoning_effort mapping, reasoning_content streaming | -| `internal/providers/dashscope.go` | DashScope: thinking budget, tools+streaming limitation fallback | +| `internal/providers/dashscope.go` | DashScope: model-specific thinking guard, budget mapping, tools+streaming fallback | +| `internal/providers/codex.go` | Codex: reasoning event streaming, OutputTokensDetails.ReasoningTokens tracking | --- diff --git a/docs/13-ws-team-events.md b/docs/13-ws-team-events.md index c8324dc29..b9090be4a 100644 --- a/docs/13-ws-team-events.md +++ b/docs/13-ws-team-events.md @@ -177,14 +177,16 @@ Emitted when a new team task is created (manual or auto-created by delegation). "status": "pending", "owner_agent_key": "", "user_id": "user123", - "channel": "telegram", + "channel": "dashboard", "chat_id": "-100123456", - "timestamp": "2026-03-05T10:00:00Z" + "timestamp": "2026-03-05T10:00:00Z", + "actor_type": "human", + "actor_id": "user123" } ``` #### `team.task.claimed` -Emitted when an agent claims a task (manual or auto-claim before delegation). +Emitted when an agent claims a task (reserved for future use; not currently emitted). ```json { @@ -194,14 +196,32 @@ Emitted when an agent claims a task (manual or auto-claim before delegation). "owner_agent_key": "tieu-la", "owner_display_name": "Tieu La", "user_id": "user123", - "channel": "delegate", + "channel": "system", "chat_id": "-100123456", "timestamp": "2026-03-05T10:00:01Z" } ``` +#### `team.task.assigned` +Emitted when a task is assigned to an agent (either auto-assigned at creation or manually via `teams.tasks.assign` RPC). + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "in_progress", + "owner_agent_key": "tieu-la", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:00:01Z", + "actor_type": "human", + "actor_id": "user123" +} +``` + #### `team.task.completed` -Emitted when a task is completed (manual or auto-completed by delegation). +Emitted when a task is completed (auto-completed by delegation or marked complete by agent). ```json { @@ -211,7 +231,7 @@ Emitted when a task is completed (manual or auto-completed by delegation). "owner_agent_key": "tieu-la", "owner_display_name": "Tieu La", "user_id": "user123", - "channel": "delegate", + "channel": "system", "chat_id": "-100123456", "timestamp": "2026-03-05T10:00:45Z" } @@ -227,12 +247,113 @@ Emitted when a task is cancelled. Separated from `team.task.completed` for corre "status": "cancelled", "reason": "Task no longer needed", "user_id": "user123", - "channel": "telegram", + "channel": "dashboard", "chat_id": "-100123456", "timestamp": "2026-03-05T10:01:00Z" } ``` +#### `team.task.approved` +Emitted when a human approves a task via the dashboard (status becomes completed). + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "completed", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:00Z", + "actor_type": "human", + "actor_id": "user123" +} +``` + +#### `team.task.rejected` +Emitted when a human rejects a task via the dashboard (status becomes cancelled with reason). + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "cancelled", + "reason": "Image aspect ratio should be 4:5", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:30Z", + "actor_type": "human", + "actor_id": "user123" +} +``` + +#### `team.task.commented` +Emitted when a human adds a comment to a task (no status change). + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:45Z" +} +``` + +#### `team.task.deleted` +Emitted when a terminal-status task is hard-deleted via the dashboard. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "completed", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:03:00Z", + "actor_type": "human", + "actor_id": "user123" +} +``` + +#### `team.task.failed` +Reserved for future use. Emitted when a task auto-execution fails (not currently triggered). + +#### `team.task.reviewed` +Reserved for future use. Emitted when a task enters review stage (not currently triggered). + +#### `team.task.progress` +Reserved for future use. Emitted periodically for long-running tasks (not currently triggered). + +#### `team.task.updated` +Reserved for future use. Emitted when task metadata is updated (not currently triggered). + +#### `team.task.stale` +Reserved for future use. Emitted when a task hasn't been updated within a timeout threshold (not currently triggered). + +--- + +### Workspace Events + +#### `workspace.file.changed` +Emitted when a file in a team's workspace directory is created, modified, or deleted. This event is reserved for future implementation; not currently broadcasted. + +```json +{ + "team_id": "019c9503-...", + "chat_id": "-100123456", + "file_name": "project/notes.md", + "change_type": "created", + "timestamp": "2026-03-05T10:00:00Z" +} +``` + +**Potential field values:** +- `change_type`: `"created"`, `"modified"`, `"deleted"` + --- ### Team Message Events @@ -317,47 +438,6 @@ These events are emitted from RPC handlers when teams are managed via the Web UI --- -### Agent Link Events (Admin) - -Emitted from RPC handlers when agent links are managed via the Web UI. - -#### `agent_link.created` -```json -{ - "link_id": "019cab12-...", - "source_agent_id": "019c839b-...", - "source_agent_key": "default", - "target_agent_id": "019ca748-...", - "target_agent_key": "tieu-la", - "direction": "outbound", - "team_id": "019c9503-...", - "status": "active" -} -``` - -#### `agent_link.updated` -```json -{ - "link_id": "019cab12-...", - "source_agent_key": "default", - "target_agent_key": "tieu-la", - "direction": "bidirectional", - "status": "active", - "changes": ["direction", "settings"] -} -``` - -#### `agent_link.deleted` -```json -{ - "link_id": "019cab12-...", - "source_agent_key": "default", - "target_agent_key": "tieu-la" -} -``` - ---- - ### Agent Events (Delegation Context) `AgentEvent` payloads (broadcast as `"event": "agent"`) now include optional delegation and routing context: @@ -474,6 +554,7 @@ User sends "Create Instagram post" to Default Agent (lead) All event name constants are defined in `pkg/protocol/events.go`: +### Delegation Lifecycle Events | Constant | Event Name | |----------|-----------| | `EventDelegationStarted` | `delegation.started` | @@ -484,18 +565,42 @@ All event name constants are defined in `pkg/protocol/events.go`: | `EventDelegationAccumulated` | `delegation.accumulated` | | `EventDelegationAnnounce` | `delegation.announce` | | `EventQualityGateRetry` | `delegation.quality_gate.retry` | -| `EventTeamTaskCreated` | `team.task.created` | -| `EventTeamTaskClaimed` | `team.task.claimed` | -| `EventTeamTaskCompleted` | `team.task.completed` | -| `EventTeamTaskCancelled` | `team.task.cancelled` | -| `EventTeamMessageSent` | `team.message.sent` | + +### Team Task Lifecycle Events +| Constant | Event Name | Status | +|----------|-----------|--------| +| `EventTeamTaskCreated` | `team.task.created` | Active | +| `EventTeamTaskClaimed` | `team.task.claimed` | Reserved (not emitted) | +| `EventTeamTaskAssigned` | `team.task.assigned` | Active | +| `EventTeamTaskCompleted` | `team.task.completed` | Active | +| `EventTeamTaskCancelled` | `team.task.cancelled` | Active | +| `EventTeamTaskApproved` | `team.task.approved` | Active | +| `EventTeamTaskRejected` | `team.task.rejected` | Active | +| `EventTeamTaskCommented` | `team.task.commented` | Active | +| `EventTeamTaskDeleted` | `team.task.deleted` | Active | +| `EventTeamTaskFailed` | `team.task.failed` | Reserved (future) | +| `EventTeamTaskReviewed` | `team.task.reviewed` | Reserved (future) | +| `EventTeamTaskProgress` | `team.task.progress` | Reserved (future) | +| `EventTeamTaskUpdated` | `team.task.updated` | Reserved (future) | +| `EventTeamTaskStale` | `team.task.stale` | Reserved (future) | + +### Team CRUD Events +| Constant | Event Name | +|----------|-----------| | `EventTeamCreated` | `team.created` | | `EventTeamUpdated` | `team.updated` | | `EventTeamDeleted` | `team.deleted` | | `EventTeamMemberAdded` | `team.member.added` | | `EventTeamMemberRemoved` | `team.member.removed` | -| `EventAgentLinkCreated` | `agent_link.created` | -| `EventAgentLinkUpdated` | `agent_link.updated` | -| `EventAgentLinkDeleted` | `agent_link.deleted` | -Typed payload structs are defined in `pkg/protocol/team_events.go`. +### Workspace Events +| Constant | Event Name | Status | +|----------|-----------|--------| +| `EventWorkspaceFileChanged` | `workspace.file.changed` | Reserved (future) | + +### Team Message Events +| Constant | Event Name | +|----------|-----------| +| `EventTeamMessageSent` | `team.message.sent` | + +**Payload structs:** Typed payloads are defined in `pkg/protocol/team_events.go` (e.g., `TeamTaskEventPayload`, `DelegationEventPayload`, etc.). diff --git a/docs/14-skills-runtime.md b/docs/14-skills-runtime.md index cd71e830f..a398513a7 100644 --- a/docs/14-skills-runtime.md +++ b/docs/14-skills-runtime.md @@ -158,7 +158,6 @@ Skills shipped with the Docker image at `/app/bundled-skills/`. Lowest priority | `docx` | Read, create, edit Word documents | | `pptx` | Read, create, edit presentations | | `skill-creator` | Create new skills | -| `ai-multimodal` | AI-powered media analysis and generation | ### How It Works diff --git a/docs/15-core-skills-system.md b/docs/15-core-skills-system.md index a1415ccec..4b3a04ab1 100644 --- a/docs/15-core-skills-system.md +++ b/docs/15-core-skills-system.md @@ -18,10 +18,10 @@ Current bundled core skills: | Slug | Purpose | |------|---------| -| `read-pdf` | Extract text from PDF files via pypdf | -| `read-docx` | Extract text from Word documents via python-docx | -| `read-pptx` | Extract text from PowerPoint files via python-pptx | -| `read-xlsx` | Read/analyze Excel spreadsheets via openpyxl | +| `pdf` | Read, create, merge, split PDF files via pypdf | +| `docx` | Read, create, edit Word documents via python-docx | +| `pptx` | Read, create, edit PowerPoint presentations via python-pptx | +| `xlsx` | Read, create, edit Excel spreadsheets via openpyxl | | `skill-creator` | Meta-skill for creating new skills | Shared helper modules live in `skills/_shared/` and are copied alongside each skill but not registered as standalone skills. @@ -91,7 +91,7 @@ skills/ ``` Each version is copied to: `///` -Example: `/app/data/skills/read-pdf/3/` +Example: `/app/data/skills/pdf/3/` --- @@ -99,11 +99,10 @@ Example: `/app/data/skills/read-pdf/3/` ```yaml --- -name: Read PDF -slug: read-pdf -description: Extract and analyze text content from PDF files +name: pdf +description: Use this skill whenever the user wants to do anything with PDF files... author: GoClaw Team -tags: [pdf, document, extraction] +tags: [pdf, document] --- ## Instructions @@ -289,9 +288,11 @@ Count ≤ 40 AND tokens ≤ 5000: → build XML block injected into system prompt: - Extract text from PDF files - Extract text from Word documents - ... + Read, create, merge, split PDF files + Read, create, edit Word documents + Read, create, edit PowerPoint presentations + Read, create, edit Excel spreadsheets + Create new skills ``` diff --git a/docs/16-skill-publishing.md b/docs/16-skill-publishing.md index 712670178..14acb69e4 100644 --- a/docs/16-skill-publishing.md +++ b/docs/16-skill-publishing.md @@ -301,13 +301,14 @@ Next agent turn picks up the new skill in its tool set. |------|---------| | `internal/tools/publish_skill.go` | Tool implementation | | `internal/skills/helpers.go` | Shared helpers: ParseSkillFrontmatter, Slugify, IsSystemArtifact, SlugRegexp | -| `internal/store/pg/skills.go` | DB operations: CreateSkillManaged, GetNextVersion, IsSystemSkill, StoreMissingDeps | +| `internal/store/pg/skills_crud.go` | DB operations: CreateSkillManaged, GetNextVersion, StoreMissingDeps | +| `internal/store/pg/skills_admin.go` | Admin operations: IsSystemSkill | | `internal/store/pg/skills_grants.go` | GrantToAgent, RevokeFromAgent, ListAccessible | | `internal/skills/loader.go` | Filesystem skill loader with priority hierarchy | | `internal/skills/seeder.go` | System skill seeder (bundled → DB) | | `internal/skills/dep_scanner.go` | Static analysis for skill dependencies | | `internal/skills/dep_checker.go` | Runtime dependency verification | | `internal/http/skills_upload.go` | HTTP ZIP upload handler (alternative to publish_skill) | -| `cmd/gateway.go` | Tool registration | +| `cmd/gateway.go` | Tool registration and gateway initialization | | `cmd/gateway_builtin_tools.go` | Builtin tool seed data | | `skills/skill-creator/SKILL.md` | Core skill instructions | diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 2679aadbb..6b8c24d66 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -8,47 +8,105 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added -#### Credentialed Exec — Secure CLI Credential Injection -- **New feature**: Direct Exec Mode for CLI tools with auto-injected credentials (GitHub, Google Cloud, AWS, Kubernetes, Terraform) -- **Security model**: No shell involved — credentials injected directly into process env; 4-layer defense (no shell, path verify, deny patterns, output scrub) -- **Presets**: 5 built-in binary configurations (gh, gcloud, aws, kubectl, terraform) -- **Database**: Migration 000019 adds `secure_cli_binaries` table for credential storage (encrypted with AES-256-GCM) -- **Tool integration**: ExecTool routes credentialed binaries to `executeCredentialed()` path, bypassing shell -- **HTTP API endpoints**: - - `GET /v1/cli-credentials` — List all credentials - - `POST /v1/cli-credentials` — Create credential - - `GET /v1/cli-credentials/{id}` — Retrieve credential - - `PUT /v1/cli-credentials/{id}` — Update credential - - `DELETE /v1/cli-credentials/{id}` — Delete credential - - `GET /v1/cli-credentials/presets` — Get preset templates - - `POST /v1/cli-credentials/{id}/test` — Dry run with test command -- **Web UI**: Credential manager with preset selector, environment variable editor, dry run tester -- **Files added**: - - `internal/tools/credentialed_exec.go` — Direct exec, shell operator detection, path verification - - `internal/tools/credential_context.go` — Context injection helpers - - `internal/store/secure_cli_store.go` — Store interface - - `internal/store/pg/secure_cli.go` — PostgreSQL implementation - - `internal/http/secure_cli.go` — HTTP endpoints - - `migrations/000019_secure_cli_binaries.up.sql` — Database schema - -#### API Key Management -- **Multi-key auth**: Multiple API keys with `goclaw_` prefix, SHA-256 hashed storage, show-once pattern -- **RBAC scopes**: `operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing` -- **HTTP + WS**: Full CRUD via `/v1/api-keys` and `api_keys.*` RPC methods -- **Web UI**: Create dialog with scope checkboxes, expiry options, revoke confirmation -- **Migration**: `000020_api_keys` — `api_keys` table with partial index on active key hashes -- **Backward compatible**: Existing gateway token continues to work as admin - -#### Interactive API Documentation -- **Swagger UI** at `/docs` with embedded OpenAPI 3.0 spec at `/v1/openapi.json` -- **Coverage**: 130+ HTTP endpoints across 18 tag groups -- **Sidebar link**: API Docs entry in System group (opens in new tab) +#### Team Workspace Improvements (2026-03-16) +- **Team workspace resolution**: Lead agents resolve per-team workspace directories for both lead and member agents +- **WorkspaceInterceptor**: Transparently rewrites file tool requests to team workspace context +- **File tool access**: Member agents can access workspace files with automatic path resolution +- **Team workspace UI**: Workspace scope setting UI, file view/download, storage depth control +- **Lazy folder loading**: Improved performance with lazy-load folder UI and SSE size endpoint +- **Task enhancements**: Task snapshots in board view, task delete action, improved task dispatch concurrency +- **Board toolbar**: Moved workspace button and added agent emoji display +- **Status filter**: Default status filter changed to all with page size reduced to 30 + +#### Agent & Workspace Enhancements (2026-03-16) +- **Agent emoji**: Display emoji icon from `other_config` in agent list and detail views +- **Lead orchestration**: Improved leader orchestration prompt with better team context +- **Task blocking validation**: Validate blocked_by terminal state to prevent circular dependencies +- **Prevent premature task creation**: Team V2 leads cannot manually create tasks before spawn + +#### Team System V2 & Task Workflow (2026-03-13 - 2026-03-15) +- **Kanban board layout**: Redesigned team detail page with visual task board +- **Card/list toggle**: Teams list with card/list view toggle +- **Member enrichment**: Team member info enriched with agent metadata +- **Task approval workflow**: Approve/reject/cancel tasks with new statuses and filtering +- **Workspace scope**: Per-agent DM/group/user controls with workspace sharing configuration +- **i18n for channels**: Channel config fields now support internationalization +- **Memory/KG sharing**: Decoupled memory and KG sharing from workspace folder sharing +- **Events API**: New /v1/teams/{id}/events endpoint for task lifecycle events + +#### Security & Pairing Hardening (2026-03-16) +- **Browser approval fix**: Fixed browser approval stuck condition +- **Pairing auth hardening**: Fail-closed auth, rate limiting, TTL enforcement for pairing codes +- **DB error handling**: Handle transient DB errors in IsPaired check +- **Transient recovery**: Prevent spurious pair requests + +#### Internationalization (i18n) Expansion (2026-03-15) +- **Complete web UI localization**: Full internationalization for en/vi/zh across all UI components +- **Config centralization**: Centralized hardcoded ~/.goclaw paths via config resolution +- **Channel DM streaming**: Enable DM streaming by default with i18n field support + +#### Provider Enhancements (2026-03-14 - 2026-03-16) +- **Qwen 3.5 support**: Added Qwen 3.5 series support with per-model thinking capability +- **Anthropic prompt caching**: Corrected Anthropic prompt caching implementation +- **Anthropic model aliases**: Model alias resolution for Anthropic API +- **Datetime tool**: Added datetime tool for provider context +- **DashScope per-model thinking**: Simplified per-model thinking guard logic +- **OpenAI GPT-5/o-series**: Use max_completion_tokens and skip temperature for GPT-5/o-series models + +#### ACP Provider (2026-03-14) +- **External coding agents**: ACP provider for orchestrating external agents (Claude Code, Codex CLI, Gemini CLI) as JSON-RPC subprocesses +- **ProcessPool management**: Subprocess lifecycle with idle TTL reaping and crash recovery +- **ToolBridge**: Agent→client requests for filesystem operations and terminal spawning +- **Workspace sandboxing**: Security features with deny pattern matching and permission modes +- **Streaming support**: Both streaming and non-streaming modes with context cancellation + +#### Storage & Media Enhancements (2026-03-14) +- **Lazy folder loading**: Lazy-load folder UI for improved performance +- **SSE size endpoint**: Server-sent events endpoint for dynamic size calculation +- **Enhanced file viewer**: Improved file viewing capabilities with media preservation +- **Web fetch enhancement**: Increased limit to 60K with temp file save for oversized content +- **Discord media enrichment**: Persist media IDs for Discord image attachments + +#### Knowledge Graph Improvements (2026-03-14) +- **LLM JSON sanitization**: Sanitize LLM JSON output before parsing to handle edge cases + +#### Traces & Observability (2026-03-16) +- **Trace UI improvements**: Added timestamps, copy button, syntax highlighting to trace/span views +- **Trace export**: Added gzip export with recursive sub-trace collection + +#### Skills & System Tools (Previous releases) +- **System skills**: Toggle, dependency checking, per-item installation +- **Tool aliases**: Alias registry for Claude Code skill compatibility +- **Multi-skill upload**: Client-side validation for bulk skill uploads +- **Audio handling**: Fixed media tag enrichment and literal handling + +#### Credential & Configuration (Previous releases) +- **Credential merge**: Handle DB errors to prevent silent data loss +- **OAuth provider routing**: Complete media provider type routing for Suno, DashScope, OAuth providers +- **API base resolution**: Respect API base when listing Anthropic models +- **Per-agent DB settings**: Honor per-agent restrictions, subagents, memory, sandbox, embedding provider settings + +### Changed + +- **Team workspace refactor**: Removed legacy `workspace_read`/`workspace_write` tools in favor of file tools for team workspace +- **Config hardcoding**: Centralized ~/goclaw paths via config resolution instead of hardcoded values +- **Workspace media files**: Preserve workspace media files during subtree lazy-loading + +### Fixed + +- **Teams status filter**: Default to all statuses instead of subset, reduced page size to 30 +- **Select crash**: Filter empty chat_id scopes to prevent dropdown crash +- **File viewer**: Improved workspace file view/download and storage depth control +- **Pairing DB errors**: Handle transient errors gracefully +- **Provider thinking**: Corrected DashScope per-model thinking logic ### Documentation - Added `18-http-api.md` — Complete HTTP REST API reference (all endpoints, auth, error codes) - Added `19-websocket-rpc.md` — Complete WebSocket RPC method catalog (64+ methods, permission matrix) - Added `20-api-keys-auth.md` — API key authentication, RBAC scopes, security model, usage examples +- Updated `02-providers.md` — ACP provider documentation with architecture, configuration, security model +- Updated `00-architecture-overview.md` — Added ACP provider component and module references --- diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 671d0f244..9203dd549 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -115,7 +115,8 @@ CRUD operations for agent management. Requires `X-GoClaw-User-Id` header for mul | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/agents/{id}/instances` | List user instances | -| `GET` | `/v1/agents/{id}/instances/{userID}/files` | Get user context files | +| `GET` | `/v1/agents/{id}/instances/{userID}/files` | List user context files | +| `GET` | `/v1/agents/{id}/instances/{userID}/files/{fileName}` | Get specific user context file | | `PUT` | `/v1/agents/{id}/instances/{userID}/files/{fileName}` | Update user file (USER.md only) | | `PATCH` | `/v1/agents/{id}/instances/{userID}/metadata` | Update instance metadata | @@ -147,7 +148,7 @@ Response: `{content, run_id, usage?}`. Used by orchestrators (n8n, Paperclip) to | `GET` | `/v1/skills/{id}` | Get skill details | | `PUT` | `/v1/skills/{id}` | Update skill metadata | | `DELETE` | `/v1/skills/{id}` | Delete skill (not system skills) | -| `POST` | `/v1/skills/{id}/toggle` | Enable/disable skill | +| `POST` | `/v1/skills/{id}/toggle` | Toggle skill enabled/disabled state | ### Skill Grants @@ -157,7 +158,12 @@ Response: `{content, run_id, usage?}`. Used by orchestrators (n8n, Paperclip) to | `DELETE` | `/v1/skills/{id}/grants/agent/{agentID}` | Revoke from agent | | `POST` | `/v1/skills/{id}/grants/user` | Grant skill to user | | `DELETE` | `/v1/skills/{id}/grants/user/{userID}` | Revoke from user | -| `GET` | `/v1/agents/{agentID}/skills` | List skills with grant status | + +### Agent Skills + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/agents/{agentID}/skills` | List skills with grant status for agent | ### Skill Files & Dependencies @@ -273,6 +279,8 @@ Grants support `tool_allow` and `tool_deny` JSON arrays for fine-grained tool fi | `PUT` | `/v1/tools/custom/{id}` | Update tool | | `DELETE` | `/v1/tools/custom/{id}` | Delete tool | +Query parameters for list: `agent_id`, `search`, `limit`, `offset` + ### Direct Invocation ``` @@ -380,22 +388,68 @@ Compaction runs in the background. Falls back to hard delete if no LLM provider --- -## 14. Traces +## 14. Delegations + +Agent task delegation and authorization history. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/delegations` | List delegations (paginated, filterable) | +| `GET` | `/v1/delegations/{id}` | Get delegation record | + +**Filters:** `source_agent_id`, `target_agent_id`, `team_id`, `user_id`, `status`, `limit`, `offset` + +--- + +## 15. Team Events + +Team activity and audit trail. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/teams/{id}/events` | List team events (paginated) | + +--- + +## 16. Secure CLI Credentials + +CLI authentication credentials for secure command execution. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/cli-credentials` | List all credentials | +| `POST` | `/v1/cli-credentials` | Create new credential | +| `GET` | `/v1/cli-credentials/{id}` | Get credential details | +| `PUT` | `/v1/cli-credentials/{id}` | Update credential | +| `DELETE` | `/v1/cli-credentials/{id}` | Delete credential | +| `GET` | `/v1/cli-credentials/presets` | Get preset credential templates | +| `POST` | `/v1/cli-credentials/{id}/test` | Test credential connection (dry-run) | + +--- + +## 17. Traces & Costs LLM call tracing and cost analysis. +### Traces + | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/traces` | List traces (paginated, filterable) | | `GET` | `/v1/traces/{traceID}` | Get trace with spans | | `GET` | `/v1/traces/{traceID}/export` | Export trace tree (gzipped JSON) | -| `GET` | `/v1/costs/summary` | Cost summary by agent/time range | **Filters:** `agent_id`, `user_id`, `session_key`, `status`, `channel` +### Costs + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/costs/summary` | Cost summary by agent/time range | + --- -## 15. Usage & Analytics +## 18. Usage & Analytics | Method | Path | Description | |--------|------|-------------| @@ -409,7 +463,7 @@ LLM call tracing and cost analysis. --- -## 16. Activity & Audit +## 19. Activity & Audit | Method | Path | Description | |--------|------|-------------| @@ -417,7 +471,7 @@ LLM call tracing and cost analysis. --- -## 17. Storage +## 20. Storage Workspace file management. @@ -426,13 +480,17 @@ Workspace file management. | `GET` | `/v1/storage/files` | List files with depth limiting | | `GET` | `/v1/storage/files/{path...}` | Read file (JSON or raw) | | `DELETE` | `/v1/storage/files/{path...}` | Delete file/directory | -| `GET` | `/v1/storage/size` | Stream storage size (SSE, cached 60 min) | +| `GET` | `/v1/storage/size` | Stream storage size (Server-Sent Events, cached 60 min) | + +**Query parameters:** +- `?raw=true` — Serve native MIME type instead of JSON +- `?depth=N` — Limit directory traversal depth -Use `?raw=true` to serve native MIME type. Protected directories `skills/` and `skills-store/` cannot be deleted. Path traversal and symlink attacks are blocked. +**Security:** Protected directories `skills/` and `skills-store/` cannot be deleted. Path traversal and symlink attacks are blocked. --- -## 18. Media +## 21. Media | Method | Path | Description | |--------|------|-------------| @@ -441,7 +499,7 @@ Use `?raw=true` to serve native MIME type. Protected directories `skills/` and ` --- -## 19. Files +## 22. Files | Method | Path | Description | |--------|------|-------------| @@ -451,7 +509,7 @@ Auth via Bearer token or `?token=` query param (for `` tags). MIME type aut --- -## 20. API Keys +## 23. API Keys Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth](20-api-keys-auth.md) for the full authentication and authorization model. @@ -459,7 +517,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] |--------|------|-------------| | `GET` | `/v1/api-keys` | List all API keys (masked) | | `POST` | `/v1/api-keys` | Create API key (returns raw key once) | -| `DELETE` | `/v1/api-keys/{id}` | Revoke API key | +| `POST` | `/v1/api-keys/{id}/revoke` | Revoke API key | ### Create Request @@ -489,7 +547,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 21. OAuth +## 24. OAuth | Method | Path | Description | |--------|------|-------------| @@ -500,7 +558,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 22. System +## 25. System | Method | Path | Description | |--------|------|-------------| @@ -519,7 +577,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 23. MCP Bridge +## 26. MCP Bridge Exposes GoClaw tools to Claude CLI via streamable HTTP at `/mcp/bridge`. Only listens on localhost. Protected by gateway token with HMAC-signed context headers. @@ -558,27 +616,49 @@ Error messages are localized based on the `Accept-Language` header. HTTP status --- +## Notes on WebSocket-Only Endpoints + +The following operations are **only available via WebSocket RPC**, not HTTP: + +- **Sessions:** List, preview, patch, delete, reset (use WebSocket method `sessions.*`) +- **Cron jobs:** List, create, update, delete, logs (use WebSocket method `cron.*`) +- **Send messages:** Send to channels (use WebSocket method `send.*`) +- **Config management:** Get, apply, patch (use WebSocket method `config.*`) + +These endpoints require an active WebSocket connection to the `/ws` endpoint with proper authentication and agent context. + +--- + ## File Reference | File | Purpose | |------|---------| | `internal/http/chat_completions.go` | OpenAI-compatible chat API | | `internal/http/responses.go` | OpenResponses protocol | -| `internal/http/agents.go` | Agent CRUD + shares | -| `internal/http/skills.go` | Skill management | -| `internal/http/providers.go` | Provider CRUD | -| `internal/http/mcp.go` | MCP server management | +| `internal/http/agents.go` | Agent CRUD + shares + instances + files | +| `internal/http/skills.go` | Skill management + grants + versions | +| `internal/http/providers.go` | Provider CRUD + verification + models | +| `internal/http/mcp.go` | MCP server management + grants + requests | | `internal/http/custom_tools.go` | Custom tool CRUD | | `internal/http/builtin_tools.go` | Built-in tool management | -| `internal/http/channel_instances.go` | Channel instance management | -| `internal/http/memory.go` | Memory document management | -| `internal/http/knowledge_graph.go` | Knowledge graph API | -| `internal/http/traces.go` | LLM trace listing | -| `internal/http/usage.go` | Usage analytics | +| `internal/http/tools_invoke.go` | Direct tool invocation | +| `internal/http/channel_instances.go` | Channel instance management + contacts | +| `internal/http/memory_handlers.go` | Memory document management + search + indexing | +| `internal/http/knowledge_graph.go` | Knowledge graph API (entities, relations, traversal) | +| `internal/http/traces.go` | LLM trace listing + export | +| `internal/http/usage.go` | Usage analytics + costs | | `internal/http/activity.go` | Activity audit log | -| `internal/http/storage.go` | Workspace file management | -| `internal/http/api_keys.go` | API key management | -| `internal/http/auth.go` | Authentication helpers | +| `internal/http/storage.go` | Workspace file management + size calculation | +| `internal/http/media_upload.go` | Media file upload | +| `internal/http/media_serve.go` | Media file serving | +| `internal/http/files.go` | Workspace file serving | +| `internal/http/api_keys.go` | API key management + revoke | +| `internal/http/delegations.go` | Delegation history API | +| `internal/http/team_events.go` | Team event history API | +| `internal/http/secure_cli.go` | CLI credential management | +| `internal/http/pending_messages.go` | Pending message groups + compaction | +| `internal/http/oauth.go` | OAuth authentication flows | | `internal/http/openapi.go` | OpenAPI spec + Swagger UI | +| `internal/http/auth.go` | Authentication helpers | | `internal/gateway/server.go` | HTTP mux and route wiring | | `cmd/gateway.go` | Handler instantiation and wiring | diff --git a/docs/19-websocket-rpc.md b/docs/19-websocket-rpc.md index 1dcde572b..a8bbb525c 100644 --- a/docs/19-websocket-rpc.md +++ b/docs/19-websocket-rpc.md @@ -203,15 +203,6 @@ Delete an agent (admin only). **Request:** `{agentId, name?, content?}` -### Agent Links (Delegations) - -| Method | Description | -|--------|-------------| -| `agents.links.list` | List delegation links (direction: from/to/all) | -| `agents.links.create` | Create link between agents | -| `agents.links.update` | Update link settings | -| `agents.links.delete` | Delete link | - --- ## 4. Sessions @@ -373,6 +364,24 @@ sequenceDiagram | `teams.tasks.comments` | List comments | | `teams.tasks.events` | List task events | | `teams.tasks.assign` | Assign to member | +| `teams.tasks.delete` | Delete task | + +### Team Context + +| Method | Description | +|--------|-------------| +| `teams.known_users` | Get list of known user IDs in team | +| `teams.scopes` | Get channel/chat scopes for task routing | +| `teams.events.list` | List team task events (paginated) | + +**`teams.known_users` request:** `{teamId}` +**Response:** `{users: ["user-1", "user-2", ...]}` + +**`teams.scopes` request:** `{teamId}` +**Response:** `{scopes: [{channel, chatId, ...}]}` + +**`teams.events.list` request:** `{team_id, limit?, offset?}` +**Response:** `{events: [...], count: N}` ### Workspace @@ -465,7 +474,7 @@ Methods are gated by role. The role is determined at `connect` time from the tok ### Admin-Only Methods -`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `agents.links.*`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `teams.*`, `api_keys.*` +`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `teams.*`, `api_keys.*` ### Write Methods (Operator+) @@ -510,7 +519,6 @@ The server pushes events to connected clients via event frames. Key event types: | `internal/gateway/methods/agents_delete.go` | Agent deletion | | `internal/gateway/methods/agents_files.go` | Agent context files | | `internal/gateway/methods/agents_identity.go` | Agent identity | -| `internal/gateway/methods/agent_links.go` | Agent delegation links | | `internal/gateway/methods/config.go` | Config get/apply/patch/schema | | `internal/gateway/methods/sessions.go` | Session CRUD | | `internal/gateway/methods/skills.go` | Skill list/get/update | @@ -518,8 +526,8 @@ The server pushes events to connected clients via event frames. Key event types: | `internal/gateway/methods/channels.go` | Channel listing | | `internal/gateway/methods/channel_instances.go` | Channel instance CRUD | | `internal/gateway/methods/pairing.go` | Device pairing flow | -| `internal/gateway/methods/teams.go` | Team list | -| `internal/gateway/methods/teams_crud.go` | Team CRUD | +| `internal/gateway/methods/teams.go` | Team list, create, get, delete, context methods | +| `internal/gateway/methods/teams_crud.go` | Team CRUD operations | | `internal/gateway/methods/teams_members.go` | Team membership | | `internal/gateway/methods/teams_tasks.go` | Team task management | | `internal/gateway/methods/teams_workspace.go` | Team workspace | diff --git a/docs/20-api-keys-auth.md b/docs/20-api-keys-auth.md index 9de9144b1..193d98604 100644 --- a/docs/20-api-keys-auth.md +++ b/docs/20-api-keys-auth.md @@ -28,7 +28,9 @@ Or in WebSocket `connect`: {"method": "connect", "params": {"token": "my-secret-token", "user_id": "admin"}} ``` -> The gateway token is compared using timing-safe comparison to prevent timing attacks. +### Security + +The gateway token is compared using **constant-time comparison** (`crypto/subtle.ConstantTimeCompare`) in both HTTP (`auth.go:tokenMatch`) and WebSocket (`router.go:handleConnect`) to prevent timing attacks. The comparison reveals no information about where the provided token first differs from the expected token. --- @@ -58,58 +60,102 @@ On authentication, the incoming token is hashed and looked up in the `api_keys` ### Show-Once Pattern -The raw key is returned **only once** in the create response. All subsequent list/get calls show only the `prefix` field (first 8 chars after `goclaw_`). Users must copy the key immediately. +The raw key is returned **only once** in the create response. All subsequent list/get calls show only the `prefix` field (8 hex characters representing the first 4 bytes of the random component). Users must copy the key immediately after creation, as it cannot be retrieved again. --- ## 3. RBAC Scopes -Each API key is assigned one or more scopes that determine what operations it can perform. +Each API key is assigned one or more scopes that determine what operations it can perform. Scopes are validated against the set of valid scopes at creation time. -| Scope | Description | Derived Role | -|-------|-------------|-------------| -| `operator.admin` | Full access (equivalent to gateway token) | Admin | -| `operator.read` | Read-only access to all resources | Viewer | -| `operator.write` | Read + write access (chat, sessions, cron) | Operator | -| `operator.approvals` | Manage exec approvals | Operator | -| `operator.pairing` | Manage device pairings | Operator | +| Scope | Description | +|-------|-------------| +| `operator.admin` | Full access — equivalent to gateway token, can manage API keys and SecureCLI configs | +| `operator.read` | Read-only access to agents, sessions, skills, chat history, and metadata | +| `operator.write` | Read + write access — can send chat messages, manage sessions, trigger cron jobs | +| `operator.approvals` | Manage shell command execution approvals (approve/deny exec requests) | +| `operator.pairing` | Manage browser device pairings (list, revoke paired devices) | ### Role Derivation -The highest-privilege scope determines the effective role: +The highest-privilege scope determines the effective role via `RoleFromScopes()` in `permissions/policy.go`: ``` -admin scope present → RoleAdmin -write/approvals/pairing → RoleOperator -read only → RoleViewer +if admin scope present → RoleAdmin +if write/approvals/pairing scope → RoleOperator +if read scope only → RoleViewer +default → RoleViewer ``` -The role is then used by the `PolicyEngine` to gate method access (see [19 — WebSocket RPC](19-websocket-rpc.md#17-permission-matrix)). +The derived role is then used by the `PolicyEngine.CanAccess()` method to gate RPC method access (see [19 — WebSocket RPC](19-websocket-rpc.md#17-permission-matrix)). --- ## 4. Authentication Flow +### Prioritized Auth Paths + +GoClaw tries authentication methods in this priority order: + +1. **Gateway token** (exact match via constant-time comparison) → `RoleAdmin` +2. **API key** (SHA-256 hash lookup in `api_keys` table) → role from scopes +3. **Browser pairing** (sender ID must be paired with "browser" device type) → `RoleOperator` (HTTP only; requires `X-GoClaw-Sender-Id` header) +4. **No auth configured** (backward compatibility: if no gateway token is set) → `RoleOperator` +5. **No valid auth found** → `401 Unauthorized` + +### HTTP Request Flow + ```mermaid flowchart TD - A[Incoming request] --> B{Bearer token?} - B -->|No| C[401 Unauthorized] - B -->|Yes| D{Match gateway token?} - D -->|Yes| E[Admin role] - D -->|No| F[SHA-256 hash token] - F --> G{Lookup in api_keys} - G -->|Not found| C - G -->|Found + revoked| C - G -->|Found + expired| C - G -->|Found + valid| H[Derive role from scopes] - H --> I[Set role on context] + A[Incoming HTTP request] --> B{Authorization header?} + B -->|No| C{X-GoClaw-Sender-Id header?} + B -->|Yes, extract Bearer token| D{Match gateway token?} + D -->|Yes| E[RoleAdmin] + D -->|No| F[Hash token + lookup in api_keys] + F --> G{Found + valid?} + G -->|Yes| H[Derive role from scopes] + G -->|No| I{Gateway token configured?} + I -->|Yes| J[401 Unauthorized] + I -->|No| K[RoleOperator - backward compat] + C -->|Check paired device| L{Device paired?} + L -->|Yes| M[RoleOperator] + L -->|No| J + E --> N[Authenticate request] + H --> N + K --> N + M --> N ``` -This flow is identical for both HTTP (`internal/http/auth.go`) and WebSocket (`internal/gateway/router.go` — connect handler). +### WebSocket Connect Flow + +The same auth paths apply for WebSocket `connect` messages. The connection parameter `token` is checked against the gateway token first, then API keys, then browser pairing. + +### API Key Caching + +API keys are cached in-memory with a 5-minute TTL to reduce database load. The cache: +- Maps SHA-256 hash → `APIKeyData` + derived role +- Supports **negative caching** (tracks failed lookups) to prevent repeated DB queries for invalid tokens +- Caps negative cache to 10,000 entries to prevent memory exhaustion from token spraying attacks +- Is invalidated globally via pubsub on key creation, revocation, or update ### Last-Used Tracking -On successful API key authentication, `last_used_at` is updated asynchronously (via goroutine) to avoid blocking the request path. +On successful API key authentication, `last_used_at` is updated asynchronously (fire-and-forget goroutine with 5-second timeout) to avoid blocking the request path. This tracks API usage without slowing down authentication. + +--- + +## 5. Authentication Priority & Backward Compatibility + +### HTTP Request Headers + +- **Bearer token**: `Authorization: Bearer ` — checked first for gateway token or API key +- **User ID**: `X-GoClaw-User-Id: ` — optional external user identifier (max 255 chars) +- **Browser pairing**: `X-GoClaw-Sender-Id: ` — identifies a previously-paired browser device +- **Locale**: `Accept-Language` — user's preferred language (en, vi, zh; default: en) + +### Backward Compatibility + +If no gateway token is configured (`gateway.token` is empty in `config.json`), all unauthenticated requests are granted `RoleOperator` access. This enables self-hosted deployments without strict authentication. Once a gateway token is configured, all requests must authenticate or use browser pairing. --- @@ -117,25 +163,27 @@ On successful API key authentication, `last_used_at` is updated asynchronously ( ```sql CREATE TABLE api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - prefix TEXT NOT NULL, - key_hash TEXT NOT NULL, - scopes TEXT[] NOT NULL DEFAULT '{}', - expires_at TIMESTAMPTZ, - last_used_at TIMESTAMPTZ, - revoked BOOLEAN NOT NULL DEFAULT FALSE, - created_by TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL, + prefix VARCHAR(8) NOT NULL, -- first 8 chars for display + key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex digest (unique constraint) + scopes TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'operator.admin','operator.read'} + expires_at TIMESTAMPTZ, -- NULL = never expires + last_used_at TIMESTAMPTZ, + revoked BOOLEAN NOT NULL DEFAULT false, + created_by VARCHAR(255), -- user ID who created the key + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX idx_api_keys_hash_active - ON api_keys (key_hash) - WHERE NOT revoked; +-- Fast lookup by hash (only active, non-revoked keys) +CREATE INDEX idx_api_keys_key_hash ON api_keys (key_hash) WHERE NOT revoked; + +-- Fast lookup by prefix (for display/search in web UI) +CREATE INDEX idx_api_keys_prefix ON api_keys (prefix); ``` -The partial index ensures only active (non-revoked) keys are searched during authentication, keeping lookups fast regardless of how many keys have been revoked over time. +The partial index on `key_hash` ensures only active (non-revoked) keys are searched during authentication, keeping lookups fast regardless of how many keys have been revoked over time. The `key_hash` column has a UNIQUE constraint, so only one key can have a given hash. --- @@ -147,7 +195,7 @@ The partial index ensures only active (non-revoked) keys are searched during aut |--------|------|-------------| | `GET` | `/v1/api-keys` | List all keys (masked) | | `POST` | `/v1/api-keys` | Create key | -| `DELETE` | `/v1/api-keys/{id}` | Revoke key | +| `POST` | `/v1/api-keys/{id}/revoke` | Revoke key | ### WebSocket RPC @@ -223,7 +271,76 @@ The gateway token continues to work exactly as before. API keys are an additiona --- -## 8. Web UI +## 8. SecureCLI — CLI Credential Injection + +SecureCLI is a feature that allows GoClaw to automatically inject credentials into CLI tools (e.g., `gh`, `gcloud`, `aws`) without requiring the agent to handle plaintext secrets. Credentials are stored encrypted at rest and injected at process startup. + +### Use Case + +When an agent needs to run `gh auth`, `gcloud auth`, or other authenticated CLI commands, the admin can configure a SecureCLI binary with encrypted environment variables. The agent never sees the raw credentials — they are injected directly into the child process environment via Direct Exec Mode. + +### Database Schema + +```sql +CREATE TABLE secure_cli_binaries ( + id UUID PRIMARY KEY, + binary_name TEXT NOT NULL, -- "gh", "gcloud", "aws", etc. + binary_path TEXT, -- optional absolute path (auto-resolved if null) + description TEXT, + encrypted_env BYTEA NOT NULL, -- AES-256-GCM encrypted JSON + deny_args JSONB DEFAULT '[]', -- regex patterns to block subcommands + deny_verbose JSONB DEFAULT '[]', -- verbose flag patterns + timeout_seconds INTEGER DEFAULT 30, + tips TEXT, -- injected into TOOLS.md context for agents + agent_id UUID, -- null = global (all agents), else agent-specific + enabled BOOLEAN DEFAULT true, + created_by TEXT, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +### HTTP REST API + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/cli-credentials` | List all SecureCLI configurations | +| `POST` | `/v1/cli-credentials` | Create a new SecureCLI credential config | +| `GET` | `/v1/cli-credentials/{id}` | Get a specific SecureCLI config | +| `PUT` | `/v1/cli-credentials/{id}` | Update a SecureCLI config | +| `DELETE` | `/v1/cli-credentials/{id}` | Delete a SecureCLI config | +| `POST` | `/v1/cli-credentials/{id}/test` | Dry-run test (requires admin) | +| `GET` | `/v1/cli-credentials/presets` | List preset templates for common CLIs | + +### Features + +- **Agent-specific or global:** Configs can be scoped to a single agent or shared across all agents (agent_id = null) +- **Encryption:** Environment variables are encrypted with AES-256-GCM (same as other secrets) +- **Deny patterns:** Regex patterns to block dangerous subcommands (e.g., prevent `gh auth logout`) +- **Timeout:** Per-config timeout override for long-running CLI operations +- **Tips:** Admin-provided hints injected into agent TOOLS.md context to guide which CLI commands are safe + +### Example Configuration + +```json +{ + "binary_name": "gh", + "binary_path": "/usr/local/bin/gh", + "description": "GitHub CLI for PRs and issues", + "encrypted_env": { + "GH_TOKEN": "ghp_xxxxxxxxxxxx" + }, + "deny_args": ["auth", "logout"], + "deny_verbose": ["--verbose", "-v"], + "timeout_seconds": 60, + "tips": "Use 'gh pr list' to search PRs, 'gh issue create' to open issues. Avoid 'auth' and 'logout' commands.", + "agent_id": null +} +``` + +--- + +## 9. Web UI The API Keys management page is accessible from the sidebar under **System > API Keys** (admin only). @@ -235,7 +352,7 @@ Features: --- -## 9. Usage Examples +## 10. Usage Examples ### cURL with API Key @@ -271,18 +388,21 @@ curl -X POST -H "Authorization: Bearer gateway-admin-token" \ --- -## File Reference +## 11. File Reference | File | Purpose | |------|---------| | `internal/crypto/apikey.go` | Key generation + SHA-256 hashing | | `internal/store/api_key_store.go` | Store interface + `APIKeyData` struct | -| `internal/store/pg/api_keys.go` | PostgreSQL implementation | -| `internal/http/api_keys.go` | HTTP API handler | -| `internal/http/auth.go` | HTTP auth middleware (resolveAPIKey) | +| `internal/store/secure_cli_store.go` | SecureCLI store interface + `SecureCLIBinary` struct | +| `internal/store/pg/api_keys.go` | PostgreSQL API key implementation | +| `internal/store/pg/secure_cli.go` | PostgreSQL SecureCLI implementation | +| `internal/http/api_keys.go` | HTTP API handler for API keys | +| `internal/http/secure_cli.go` | HTTP API handler for SecureCLI | +| `internal/http/auth.go` | HTTP auth middleware (resolveAPIKey, tokenMatch) | +| `internal/http/api_key_cache.go` | In-memory API key cache with TTL + pubsub invalidation | | `internal/gateway/router.go` | WebSocket connect auth (API key path) | -| `internal/gateway/methods/api_keys.go` | WebSocket RPC methods | -| `internal/permissions/policy.go` | RBAC policy engine | -| `internal/permissions/scope.go` | Scope constants + RoleFromScopes | -| `migrations/000020_api_keys.up.sql` | Database migration | +| `internal/gateway/methods/api_keys.go` | WebSocket RPC methods for API keys | +| `internal/permissions/policy.go` | RBAC policy engine + role derivation + scope validation | +| `migrations/000020_secure_cli_and_api_keys.up.sql` | Database migration (api_keys + secure_cli_binaries) | | `ui/web/src/pages/api-keys/` | Web UI components | diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 7c3151a87..aa40cc328 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -133,19 +133,48 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) ctx = tools.WithToolWorkspace(ctx, l.workspace) } - // Team workspace override: when a member agent runs a team task, use the - // team's workspace (lead agent's workspace) for file operations instead of - // the member's personal workspace. Memory/KG/skills are unaffected (DB-backed). + // Team workspace handling: + // - Dispatched task (req.TeamWorkspace set): override default workspace so + // relative paths resolve to team workspace. Agent workspace is accessible + // via ToolTeamWorkspace for absolute-path access. + // - Direct chat (auto-resolved): keep agent workspace as default, team + // workspace accessible via absolute path. if req.TeamWorkspace != "" { if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil { slog.Warn("failed to create team workspace directory", "workspace", req.TeamWorkspace, "error", err) } - ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace) + ctx = tools.WithToolTeamWorkspace(ctx, req.TeamWorkspace) + ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace) // default for relative paths } if req.TeamID != "" { ctx = tools.WithToolTeamID(ctx, req.TeamID) } + // Auto-resolve team workspace for agents not dispatched via team task. + // Lead agents default to team workspace (primary job is team coordination). + // Non-lead members keep own workspace; team workspace is accessible via absolute path. + if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil { + if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil { + // Shared workspace: scope by teamID only. Isolated (default): scope by chatID too. + wsChat := req.ChatID + if wsChat == "" { + wsChat = req.UserID + } + if tools.IsSharedWorkspace(team.Settings) { + wsChat = "" + } + if wsDir, err := tools.WorkspaceDir(l.dataDir, team.ID, wsChat); err == nil { + ctx = tools.WithToolTeamWorkspace(ctx, wsDir) + if team.LeadAgentID == l.agentUUID { + ctx = tools.WithToolWorkspace(ctx, wsDir) + } + } + if req.TeamID == "" { + ctx = tools.WithToolTeamID(ctx, team.ID.String()) + } + } + } + // Persist agent UUID + user ID on the session (for querying/tracing) if l.agentUUID != uuid.Nil || req.UserID != "" { l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID) @@ -321,17 +350,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // knows images were received and stored (consistent with audio/video enrichment). l.enrichImageIDs(messages, mediaRefs) - // 2f. Cross-session recovery: notify team leads about orphaned pending tasks - // and in-progress tasks being handled by delegates. - // Safe because Bước 1 (early ClaimTask) ensures running tasks are in_progress, - // so only truly un-spawned tasks remain pending. + // 2f. Cross-session task reminder: notify team leads about pending and in-progress tasks. + // Stale recovery (expired lock → pending) is handled by the background TaskTicker. if l.teamStore != nil && l.agentUUID != uuid.Nil { if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID { - // Recover tasks with expired locks (stale in_progress → pending) - if recovered, err := l.teamStore.RecoverStaleTasks(ctx, team.ID); err == nil && recovered > 0 { - slog.Info("recovered stale tasks", "team", team.ID, "count", recovered) - } - if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID, "", ""); err == nil { + if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "active", req.UserID, "", "", 0); err == nil { var stale []string var inProgress []string for _, t := range tasks { @@ -392,6 +415,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) var deliverables []string // actual content from tool outputs (for team task results) var blockReplies int // count of block.reply events emitted (for dedup in consumer) var lastBlockReply string // last block reply content + sentMedia := map[string]bool{} // media paths already sent by message tool (dedup) // Mid-loop compaction: summarize in-memory messages when context exceeds threshold. // Uses same config as maybeSummarize (contextWindow * historyShare). @@ -399,9 +423,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Team task orphan detection: track team_tasks create vs spawn calls. // If the LLM creates tasks but forgets to spawn, inject a reminder. - var teamTaskCreates int // count of team_tasks action=create calls - var teamTaskSpawns int // count of spawn calls with team_task_id - var teamTaskRetried bool // only retry once to prevent infinite loops + var teamTaskCreates int // count of team_tasks action=create calls + var teamTaskSpawns int // count of spawn calls with team_task_id // Inject retry hook so channels can update placeholder on LLM retries. ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, err error) { @@ -602,37 +625,6 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // No tool calls → done if len(resp.ToolCalls) == 0 { - // Guard: detect orphaned team_tasks create (created but not spawned) — v2 only. - // Query DB for actual pending tasks instead of just counting tool calls, - // because auto-created tasks (from spawn without team_task_id) bypass the counter. - if teamTaskCreates > teamTaskSpawns && !teamTaskRetried { - if l.teamStore != nil && l.agentUUID != uuid.Nil { - if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && tools.IsTeamV2(team) { - if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID, "", ""); err == nil { - var pendingIDs []string - for _, t := range tasks { - if t.Status == store.TeamTaskStatusPending { - pendingIDs = append(pendingIDs, t.ID.String()) - } - } - if len(pendingIDs) > 0 { - teamTaskRetried = true - slog.Warn("team task orphan detected", - "agent", l.id, "pending", len(pendingIDs), - "creates", teamTaskCreates, "spawns", teamTaskSpawns) - messages = append(messages, - providers.Message{Role: "assistant", Content: resp.Content}, - providers.Message{ - Role: "user", - Content: fmt.Sprintf("[System] You have %d pending task(s) awaiting dispatch: %s. These will be auto-dispatched to team members. If no longer needed, cancel with team_tasks action=cancel.", len(pendingIDs), strings.Join(pendingIDs, ", ")), - }, - ) - continue - } - } - } - } - } // Mid-run injection (Point B): drain all buffered user follow-up messages // before exiting. If found, save current assistant response and continue // the loop so the LLM can respond to the injected messages. @@ -788,6 +780,18 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) l.scanWebToolResult(tc.Name, result) + // Track media sent directly by message tool (dedup with RunResult.Media). + if tc.Name == "message" && !result.IsError { + if msg, _ := tc.Arguments["message"].(string); strings.HasPrefix(strings.TrimSpace(msg), "MEDIA:") { + // message tool sent this media via outbound bus — mark path as sent. + mediaPath := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(msg), "MEDIA:")) + if nl := strings.IndexByte(mediaPath, '\n'); nl >= 0 { + mediaPath = mediaPath[:nl] + } + sentMedia[filepath.Base(mediaPath)] = true + } + } + // Collect MEDIA: paths from tool results. // Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates. if len(result.Media) > 0 { @@ -933,6 +937,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) l.scanWebToolResult(r.tc.Name, r.result) + // Track media sent directly by message tool (dedup with RunResult.Media). + if r.tc.Name == "message" && !r.result.IsError { + if msg, _ := r.tc.Arguments["message"].(string); strings.HasPrefix(strings.TrimSpace(msg), "MEDIA:") { + mediaPath := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(msg), "MEDIA:")) + if nl := strings.IndexByte(mediaPath, '\n'); nl >= 0 { + mediaPath = mediaPath[:nl] + } + sentMedia[filepath.Base(mediaPath)] = true + } + } + // Collect MEDIA: paths from tool results. // Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates. if len(r.result.Media) > 0 { @@ -1079,6 +1094,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // (e.g. once via ForwardMedia and again when the LLM reads the file). mediaResults = deduplicateMedia(mediaResults) + // Exclude media already sent by message tool to avoid double delivery. + if len(sentMedia) > 0 { + filtered := mediaResults[:0] + for _, mr := range mediaResults { + if !sentMedia[filepath.Base(mr.Path)] { + filtered = append(filtered, mr) + } + } + mediaResults = filtered + } + return &RunResult{ Content: finalContent, RunID: req.RunID, diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 7a9055a90..b6e9b6a0b 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -138,6 +138,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s HasMemory: l.hasMemory, HasSpawn: l.tools != nil && hasSpawn, HasTeam: hasTeamTools, + TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx), HasSkillSearch: hasSkillSearch, HasMCPToolSearch: hasMCPToolSearch, HasKnowledgeGraph: hasKG, diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 8e9ac64c6..f7805a5bf 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -274,6 +274,11 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st return } + previewLimit := 500 + if collector.Verbose() { + previewLimit = 100000 + } + spanName := l.id span := store.SpanData{ ID: agentSpanID, @@ -285,7 +290,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st Level: store.SpanLevelDefault, Model: l.model, Provider: l.provider.Name(), - InputPreview: truncateStr(inputPreview, 500), + InputPreview: truncateStr(inputPreview, previewLimit), CreatedAt: start, } // Nest under parent root span if this is an announce run. diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 33acd66fa..edc61dddc 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -48,6 +48,7 @@ type Loop struct { maxIterations int maxToolCalls int workspace string + dataDir string // global workspace root for team workspace resolution workspaceSharing *store.WorkspaceSharingConfig // Per-agent overrides from DB (nil = use global defaults) @@ -160,6 +161,7 @@ type LoopConfig struct { MaxIterations int MaxToolCalls int Workspace string + DataDir string // global workspace root for team workspace resolution WorkspaceSharing *store.WorkspaceSharingConfig // Per-agent DB overrides (nil = use global defaults) @@ -283,6 +285,7 @@ func NewLoop(cfg LoopConfig) *Loop { maxIterations: cfg.MaxIterations, maxToolCalls: cfg.MaxToolCalls, workspace: cfg.Workspace, + dataDir: cfg.DataDir, workspaceSharing: cfg.WorkspaceSharing, restrictToWs: cfg.RestrictToWs, subagentsCfg: cfg.SubagentsCfg, diff --git a/internal/agent/media_tool_routing.go b/internal/agent/media_tool_routing.go index ada0d0932..e2231837e 100644 --- a/internal/agent/media_tool_routing.go +++ b/internal/agent/media_tool_routing.go @@ -13,6 +13,7 @@ import ( // hasReadImageProvider checks if the read_image builtin tool has a dedicated provider configured. // When true, images should NOT be attached inline to the main LLM — instead the agent // uses the read_image tool which routes to the configured vision provider. +// Supports both legacy flat format {"provider":"X"} and chain format {"providers":[...]}. func (l *Loop) hasReadImageProvider() bool { if l.builtinToolSettings == nil { return false @@ -21,14 +22,31 @@ func (l *Loop) hasReadImageProvider() bool { if !ok || len(raw) == 0 { return false } - // Check if provider field is set (non-empty JSON with provider key). - var cfg struct { + + // Try chain format first: {"providers":[{"provider":"X",...}]} + var chain struct { + Providers []struct { + Provider string `json:"provider"` + Enabled *bool `json:"enabled,omitempty"` + } `json:"providers"` + } + if json.Unmarshal(raw, &chain) == nil && len(chain.Providers) > 0 { + for _, p := range chain.Providers { + if p.Provider != "" && (p.Enabled == nil || *p.Enabled) { + return true + } + } + } + + // Fallback: legacy flat format {"provider":"X"} + var legacy struct { Provider string `json:"provider"` } - if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Provider == "" { - return false + if json.Unmarshal(raw, &legacy) == nil && legacy.Provider != "" { + return true } - return true + + return false } // loadHistoricalImagesForTool collects image MediaRefs from historical messages diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 106aca9c2..9bd3a0ab2 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -58,6 +58,7 @@ type ResolverDeps struct { // Agent teams TeamStore store.TeamStore + DataDir string // global workspace root for team workspace resolution // Secure CLI credential store for credentialed exec SecureCLIStore store.SecureCLIStore @@ -313,6 +314,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { MaxTokens: ag.ParseMaxTokens(), MaxIterations: maxIter, Workspace: workspace, + DataDir: deps.DataDir, RestrictToWs: &restrictVal, SubagentsCfg: ag.ParseSubagentsConfig(), MemoryCfg: ag.ParseMemoryConfig(), diff --git a/internal/agent/resolver_helpers.go b/internal/agent/resolver_helpers.go index 0f7f87f5a..b8199b012 100644 --- a/internal/agent/resolver_helpers.go +++ b/internal/agent/resolver_helpers.go @@ -83,6 +83,7 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu sb.WriteString("Do NOT use `spawn` for team delegation — `spawn` is only for self-clone subagent work.\n\n") sb.WriteString("Rules:\n") sb.WriteString("- Always specify `assignee` — match member expertise from the list above\n") + sb.WriteString("- **Check task board first** — ALWAYS call `team_tasks(action=\"list\")` before creating tasks. The system blocks creation if you skip this step\n") sb.WriteString("- Create all tasks first, then briefly tell the user what you delegated\n") sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n") sb.WriteString("- Results arrive automatically — do NOT present partial results\n") @@ -174,9 +175,10 @@ func agentToolPolicyWithMCP(policy *config.ToolPolicySpec, hasMCP bool) *config. return policy } -// agentToolPolicyWithWorkspace injects workspace_write and workspace_read into -// alsoAllow when the agent belongs to a team, ensuring the PolicyEngine doesn't -// block them even if the agent has a restrictive allow list. +// agentToolPolicyWithWorkspace injects file tools into alsoAllow when the agent +// belongs to a team, ensuring the PolicyEngine doesn't block them even if the +// agent has a restrictive allow list. File tools are now workspace-aware via +// WorkspaceInterceptor, so no separate workspace_write/workspace_read needed. func agentToolPolicyWithWorkspace(policy *config.ToolPolicySpec, hasTeam bool) *config.ToolPolicySpec { if !hasTeam { return policy @@ -184,7 +186,7 @@ func agentToolPolicyWithWorkspace(policy *config.ToolPolicySpec, hasTeam bool) * if policy == nil { policy = &config.ToolPolicySpec{} } - for _, tool := range []string{"workspace_write", "workspace_read"} { + for _, tool := range []string{"read_file", "write_file", "list_files"} { if !slices.Contains(policy.AlsoAllow, tool) { policy.AlsoAllow = append(policy.AlsoAllow, tool) } diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index a19234b83..abde4f3ef 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -32,7 +32,8 @@ type SystemPromptConfig struct { SkillsSummary string // XML from skills.Loader.BuildSummary() HasMemory bool // memory_search/memory_get available? HasSpawn bool // spawn tool available? - HasTeam bool // agent belongs to a team? (skips generic spawn section) + HasTeam bool // agent belongs to a team? (skips generic spawn section) + TeamWorkspace string // absolute path to team shared workspace (empty if not in team) ContextFiles []bootstrap.ContextFile // bootstrap files for # Project Context ExtraPrompt string // extra system prompt (subagent context, etc.) AgentType string // "open" or "predefined" — affects context file framing @@ -88,13 +89,8 @@ var coreToolSummaries = map[string]string{ "create_image": "Generate images from text descriptions using AI", "create_audio": "Generate music or sound effects from text descriptions using AI", "knowledge_graph_search": "Find people, projects, and their connections — use for relationship questions (who works with whom, project dependencies) that memory_search may miss", - "handoff": "Transfer conversation to another agent (ONLY when user explicitly asks to switch agents — NOT for task delegation)", - "evaluate_loop": "Run a generate→evaluate→revise loop between two agents for quality-critical tasks", - "delegate_search": "Search for agents by expertise to find the right delegation target", "team_tasks": "Team task board — track progress, manage dependencies (spawn auto-creates delegation tasks)", "team_message": "Send messages to teammates (progress updates, questions)", - "workspace_write": "Write files to the team shared workspace (visible to all team members)", - "workspace_read": "Read, list, delete, pin, tag files in the team shared workspace", // Claude Code tool aliases — enable Claude Code skills without modification "Read": "Alias for read_file — Read file contents", @@ -194,9 +190,9 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { // 6. ## Workspace (sandbox-aware: show container workdir when sandboxed) lines = append(lines, buildWorkspaceSection(cfg.Workspace, cfg.SandboxEnabled, cfg.SandboxContainerDir)...) - // 6.3. ## Team Workspace (when workspace tools are available) + // 6.3. ## Team Workspace (when agent belongs to a team) if hasTeamWorkspace(cfg.ToolNames) { - lines = append(lines, buildTeamWorkspaceSection()...) + lines = append(lines, buildTeamWorkspaceSection(cfg.TeamWorkspace)...) } // 6.5 ## Sandbox (matching TS sandboxInfo section) diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index f029db9fe..909670a3d 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -347,27 +347,33 @@ func hasBootstrapFile(files []bootstrap.ContextFile) bool { return false } -// hasTeamWorkspace checks if workspace_write is in the tool list. +// hasTeamWorkspace checks if team_tasks is in the tool list (indicates team context). func hasTeamWorkspace(toolNames []string) bool { - return slices.Contains(toolNames, "workspace_write") + return slices.Contains(toolNames, "team_tasks") } -// buildTeamWorkspaceSection generates guidance for team workspace tools. -func buildTeamWorkspaceSection() []string { +// buildTeamWorkspaceSection generates guidance for team workspace file tools. +// teamWsPath is the absolute path to the team shared workspace directory. +func buildTeamWorkspaceSection(teamWsPath string) []string { + if teamWsPath == "" { + return nil + } return []string{ "## Team Shared Workspace", "", - "You are part of a team. Use the shared workspace to collaborate with teammates:", + fmt.Sprintf("Your team has a shared workspace at: %s", teamWsPath), "", - "- **workspace_write**: Write files to share with the team (reports, data, code, notes).", - " Use this instead of write_file when the output needs to be shared with teammates.", - "- **workspace_read**: List, read, delete, pin, or tag shared files.", + fmt.Sprintf("- Use list_files(path=\"%s\") to browse shared files", teamWsPath), + fmt.Sprintf("- Use read_file(path=\"%s/filename.md\") to read team files", teamWsPath), + fmt.Sprintf("- Use write_file(path=\"%s/filename.md\", content=\"...\") to write team files", teamWsPath), + "- All files in the team workspace are visible to all team members", + "- Your default workspace (for relative paths) is your personal workspace", + "- To delete a team file, use write_file with empty content", "", - "Guidelines:", - "- When producing deliverables or intermediate results for the team, ALWAYS use workspace_write (not write_file).", - "- write_file is for your private workspace only. workspace_write is for team-shared files.", - "- Use workspace_read action=list to see what files teammates have shared.", - "- Before starting work, check the workspace for relevant files from other team members.", + "## Auto-Status Updates", + "You may receive [Auto-status] messages about team task progress.", + "These are informational — simply relay the update to the user naturally.", + "Do NOT create, retry, reassign, or modify tasks based on these updates.", "", } } diff --git a/internal/bus/inbound_debounce.go b/internal/bus/inbound_debounce.go index 6506ce7e0..6b865340b 100644 --- a/internal/bus/inbound_debounce.go +++ b/internal/bus/inbound_debounce.go @@ -38,7 +38,8 @@ func NewInboundDebouncer(debounceMs time.Duration, flushFn func(InboundMessage)) } // Push adds a message to the debounce buffer. -// If debouncing is disabled or the message should bypass (media), it is flushed immediately. +// All messages (text and media) are debounced so that a file/image followed by +// a text caption within the debounce window are merged into a single agent turn. func (d *InboundDebouncer) Push(msg InboundMessage) { // Disabled: pass through immediately. if d.debounceMs <= 0 { @@ -48,13 +49,6 @@ func (d *InboundDebouncer) Push(msg InboundMessage) { key := debounceKey(msg) - // Media messages bypass debounce — flush any buffered text first, then process media. - if len(msg.Media) > 0 { - d.flushKey(key) - d.flushFn(msg) - return - } - d.mu.Lock() defer d.mu.Unlock() @@ -79,7 +73,8 @@ func (d *InboundDebouncer) Push(msg InboundMessage) { "key", key, "debounce_ms", d.debounceMs.Milliseconds()) } else { slog.Debug("inbound debounce: message appended", - "key", key, "buffered", len(buf.messages)) + "key", key, "buffered", len(buf.messages), + "has_media", len(msg.Media) > 0) } } diff --git a/internal/channels/channel.go b/internal/channels/channel.go index a867ba7f4..28e350ba3 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -19,10 +19,12 @@ import ( ) // InternalChannels are system channels excluded from outbound dispatch. +// "browser" uses WebSocket directly — no outbound channel routing needed. var InternalChannels = map[string]bool{ "cli": true, "system": true, "subagent": true, + "browser": true, } // IsInternalChannel checks if a channel name is internal. diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index cba40d343..84aaa8b2a 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -139,10 +139,16 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } } if !mentioned { + // Extract file paths to preserve in history for later @mention + var mediaPaths []string + for _, mf := range mediaFiles { + mediaPaths = append(mediaPaths, mf.Path) + } c.groupHistory.Record(channelID, channels.HistoryEntry{ Sender: senderName, SenderID: senderID, Body: content, + Media: mediaPaths, Timestamp: m.Timestamp, MessageID: m.ID, }, c.historyLimit) @@ -233,6 +239,14 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } } + // Collect media from pending history entries (sent before this @mention). + // Must come after BuildContext — CollectMedia nulls out Media fields to prevent double-cleanup. + if peerKind == "group" { + for _, p := range c.groupHistory.CollectMedia(channelID) { + mediaFiles = append(mediaFiles, bus.MediaFile{Path: p}) + } + } + // Publish directly to bus (to preserve MediaFile MIME types) c.Bus().PublishInbound(bus.InboundMessage{ Channel: c.Name(), diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index 2e8af9315..61aad5b00 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "regexp" "strings" @@ -70,13 +71,18 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } } - // Clean up temp media files only. Workspace-generated files are preserved - // so they remain accessible via workspace/web UI after delivery. + // Clean up temporary media files after send (success or failure). + // Only delete files from OS temp dir — workspace files (generated/) + // should persist for user access and potential re-sends. tmpDir := os.TempDir() for _, media := range msg.Media { - if media.URL != "" && strings.HasPrefix(media.URL, tmpDir) { + if media.URL == "" { + continue + } + abs, _ := filepath.Abs(media.URL) + if strings.HasPrefix(abs, tmpDir+string(filepath.Separator)) { if err := os.Remove(media.URL); err != nil { - slog.Debug("failed to clean up media file", "path", media.URL, "error", err) + slog.Debug("failed to clean up temp media file", "path", media.URL, "error", err) } } } diff --git a/internal/channels/events.go b/internal/channels/events.go index 4daf70959..e96b722d1 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -375,7 +375,6 @@ var toolStatusMap = map[string]string{ "browser": "🌐 Browsing...", // Delegation & teams "spawn": "👥 Delegating task...", - "handoff": "🔄 Handing off...", "team_tasks": "📋 Managing team tasks...", "team_message": "💬 Sending team message...", // Sessions @@ -388,8 +387,6 @@ var toolStatusMap = map[string]string{ "cron": "⏰ Managing schedule...", "skill_search": "🔍 Searching skills...", "use_skill": "🧩 Using skill...", - "delegate_search": "🔍 Searching delegates...", - "evaluate_loop": "🔄 Evaluating...", "mcp_tool_search": "🔌 Searching MCP tools...", } diff --git a/internal/channels/history.go b/internal/channels/history.go index 2dfd76b8f..5f42c6747 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -11,6 +11,7 @@ import ( "context" "fmt" "log/slog" + "os" "strings" "sync" "time" @@ -34,6 +35,7 @@ type HistoryEntry struct { Sender string SenderID string Body string + Media []string // temp file paths for images/attachments (RAM-only, not persisted to DB) Timestamp time.Time MessageID string } @@ -122,6 +124,8 @@ func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit in existing = append(existing, entry) count = len(existing) // capture pre-trim count so MaybeCompact sees threshold exceeded if len(existing) > limit { + trimmed := existing[:len(existing)-limit] + go cleanupMedia(trimmed) existing = existing[len(existing)-limit:] } ph.entries[historyKey] = existing @@ -254,10 +258,14 @@ func (ph *PendingHistory) Clear(historyKey string) { } ph.mu.Lock() + toClean := ph.entries[historyKey] delete(ph.entries, historyKey) ph.removeFromOrder(historyKey) ph.mu.Unlock() + // Clean up any remaining media temp files (after CollectMedia took what it needed) + go cleanupMedia(toClean) + if ph.store != nil { // Remove pending flushes for this key ph.removeFromFlushBuf(historyKey) @@ -285,6 +293,35 @@ func (ph *PendingHistory) evictOldKeys() { for len(ph.order) > maxHistoryKeys { oldest := ph.order[0] ph.order = ph.order[1:] + evicted := ph.entries[oldest] delete(ph.entries, oldest) + // Clean up media temp files from evicted entries (fire-and-forget) + go cleanupMedia(evicted) + } +} + +// CollectMedia returns all media file paths from pending entries for a history key +// and removes them from the entries to prevent double-cleanup by Clear(). +func (ph *PendingHistory) CollectMedia(historyKey string) []string { + ph.mu.Lock() + defer ph.mu.Unlock() + + entries := ph.entries[historyKey] + var paths []string + for i := range entries { + paths = append(paths, entries[i].Media...) + entries[i].Media = nil // prevent double-cleanup + } + return paths +} + +// cleanupMedia removes temp files from history entries. Best-effort, logs warnings. +func cleanupMedia(entries []HistoryEntry) { + for _, e := range entries { + for _, path := range e.Media { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + slog.Warn("pending_history: media cleanup failed", "path", path, "error", err) + } + } } } diff --git a/internal/channels/telegram/commands_tasks.go b/internal/channels/telegram/commands_tasks.go index 72443730b..457fbb89a 100644 --- a/internal/channels/telegram/commands_tasks.go +++ b/internal/channels/telegram/commands_tasks.go @@ -81,7 +81,7 @@ func (c *Channel) handleTasksList(ctx context.Context, chatID int64, isGroup boo return } - tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "") + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "", 0) if err != nil { slog.Warn("tasks command: ListTasks failed", "error", err) send("Failed to list tasks. Please try again.") @@ -173,7 +173,7 @@ func (c *Channel) handleTaskDetail(ctx context.Context, chatID int64, text strin return } - tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "") + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "", 0) if err != nil { slog.Warn("task_detail command: ListTasks failed", "error", err) send("Failed to list tasks. Please try again.") @@ -236,7 +236,7 @@ func (c *Channel) handleCallbackQuery(ctx context.Context, query *telego.Callbac return } - tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "") + tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "", 0) if err != nil { send("Failed to list tasks.") return diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go index c40f768de..0482fbaca 100644 --- a/internal/channels/zalo/personal/handlers.go +++ b/internal/channels/zalo/personal/handlers.go @@ -12,6 +12,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -92,6 +93,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { Sender: senderName, SenderID: senderID, Body: content, + Media: media, Timestamp: time.Now(), MessageID: msg.Data.MsgID, }, c.historyLimit) @@ -124,12 +126,17 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { c.startTyping(threadID, protocol.ThreadTypeGroup) + // Collect media from pending history entries (images sent before this @mention). + // Must come after BuildContext — CollectMedia nulls out Media fields to prevent double-cleanup. + histMedia := c.groupHistory.CollectMedia(threadID) + allMedia := append(histMedia, media...) + metadata := map[string]string{ "message_id": msg.Data.MsgID, "platform": channels.TypeZaloPersonal, "group_id": threadID, } - c.HandleMessage(senderID, threadID, finalContent, media, metadata, "group") + c.HandleMessage(senderID, threadID, finalContent, allMedia, metadata, "group") // Clear pending history after sending to agent (matches Telegram/Discord/Slack/Feishu pattern). c.groupHistory.Clear(threadID) @@ -153,42 +160,61 @@ func (c *Channel) startTyping(threadID string, threadType protocol.ThreadType) { ctrl.Start() } -// extractContentAndMedia returns text content and optional local media paths from a message. -// For text messages, media is nil. For image attachments, the image is downloaded to a temp file. +// extractContentAndMedia returns text content (with tags) and optional local media +// file paths from a Zalo message. For text messages, media is nil. For attachments, the file +// is downloaded and classified by MIME type, matching the pattern used by Telegram/Discord/Feishu. func extractContentAndMedia(content protocol.Content) (string, []string) { if text := content.Text(); text != "" { return text, nil } att := content.ParseAttachment() - if att == nil { + if att == nil || att.Href == "" { return "", nil } - text := content.AttachmentText() - if text == "" { + + // Download the attachment file. + filePath, err := downloadFile(context.Background(), att.Href) + if err != nil { + slog.Warn("zalo_personal: failed to download attachment", "url", att.Href, "error", err) + // Return human-readable fallback so the message isn't silently dropped. + if text := content.AttachmentText(); text != "" { + return text, nil + } return "", nil } - var media []string - if att.IsImage() { - if path, err := downloadFile(context.Background(), att.Href); err != nil { - slog.Warn("zalo_personal: failed to download image", "url", att.Href, "error", err) - } else { - media = []string{path} - } + + // Classify by MIME type (image, video, audio, document) — same as Discord/Feishu. + mimeType := media.DetectMIMEType(filePath) + mediaKind := media.MediaKindFromMime(mimeType) + + // For images, also check via Zalo CDN path patterns (e.g. /jpg/, /png/) since + // temp files lose the original extension context. + if mediaKind != media.TypeImage && att.IsImage() { + mediaKind = media.TypeImage } - return text, media + + // Build the tag that the agent loop's enrichImageIDs/enrichMediaIDs expects. + tag := media.BuildMediaTags([]media.MediaInfo{{ + Type: mediaKind, + FilePath: filePath, + ContentType: mimeType, + FileName: att.Title, + }}) + + return tag, []string{filePath} } -const maxImageBytes = 10 * 1024 * 1024 // 10MB +const maxMediaBytes = 20 * 1024 * 1024 // 20MB (matches Telegram default) -// downloadFile downloads an image URL to a temp file and returns the local path. -// Validates against SSRF and enforces HTTPS-only, timeout, and size limits. -func downloadFile(ctx context.Context, imageURL string) (string, error) { - if err := tools.CheckSSRF(imageURL); err != nil { +// downloadFile downloads a URL to a temp file and returns the local path. +// Validates against SSRF and enforces timeout and size limits. +func downloadFile(ctx context.Context, fileURL string) (string, error) { + if err := tools.CheckSSRF(fileURL); err != nil { return "", fmt.Errorf("ssrf check: %w", err) } client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return "", fmt.Errorf("download: %w", err) } @@ -202,14 +228,14 @@ func downloadFile(ctx context.Context, imageURL string) (string, error) { return "", fmt.Errorf("download status %d", resp.StatusCode) } - // Strip query params before extracting extension - path := imageURL + // Strip query params before extracting extension. + path := fileURL if i := strings.IndexByte(path, '?'); i >= 0 { path = path[:i] } ext := filepath.Ext(path) if ext == "" || len(ext) > 5 { - ext = ".jpg" + ext = ".bin" } tmpFile, err := os.CreateTemp("", "goclaw_zca_*"+ext) @@ -218,14 +244,14 @@ func downloadFile(ctx context.Context, imageURL string) (string, error) { } defer tmpFile.Close() - written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxImageBytes+1)) + written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxMediaBytes+1)) if err != nil { os.Remove(tmpFile.Name()) return "", fmt.Errorf("save: %w", err) } - if written > maxImageBytes { + if written > maxMediaBytes { os.Remove(tmpFile.Name()) - return "", fmt.Errorf("image too large: %d bytes", written) + return "", fmt.Errorf("file too large: %d bytes (max %d)", written, maxMediaBytes) } return tmpFile.Name(), nil diff --git a/internal/channels/zalo/personal/protocol/send_helpers.go b/internal/channels/zalo/personal/protocol/send_helpers.go index f50d1994e..85e5ffd10 100644 --- a/internal/channels/zalo/personal/protocol/send_helpers.go +++ b/internal/channels/zalo/personal/protocol/send_helpers.go @@ -6,8 +6,10 @@ import ( "encoding/hex" "fmt" "io" + "log/slog" "mime/multipart" "os" + "path/filepath" ) // maxUploadSize is the maximum file size for Zalo uploads (25 MB). @@ -17,6 +19,14 @@ const maxUploadSize = 25 * 1024 * 1024 func checkFileSize(filePath string) error { fi, err := os.Stat(filePath) if err != nil { + // Log directory listing for diagnosis when file is missing. + dir := filepath.Dir(filePath) + entries, _ := os.ReadDir(dir) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + slog.Warn("checkFileSize: file missing", "file", filepath.Base(filePath), "dir", dir, "dir_entries", names) return fmt.Errorf("zalo_personal: stat file: %w", err) } if fi.Size() > maxUploadSize { diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go index 99c9bf901..696417307 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -23,10 +23,11 @@ type PairingMethods struct { msgBus *bus.MessageBus onApprove PairingApproveCallback broadcaster func(protocol.EventFrame) + rateLimiter *gateway.RateLimiter } -func NewPairingMethods(service store.PairingStore, msgBus *bus.MessageBus) *PairingMethods { - return &PairingMethods{service: service, msgBus: msgBus} +func NewPairingMethods(service store.PairingStore, msgBus *bus.MessageBus, rateLimiter *gateway.RateLimiter) *PairingMethods { + return &PairingMethods{service: service, msgBus: msgBus, rateLimiter: rateLimiter} } // SetOnApprove sets a callback that fires after a pairing is approved. @@ -203,6 +204,11 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien // handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved. // Called by unauthenticated clients during the browser pairing flow. func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + // Rate-limit unauthenticated polling to prevent sender_id enumeration. + if m.rateLimiter != nil && m.rateLimiter.Enabled() && !m.rateLimiter.Allow("pairing:"+client.RemoteAddr()) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrResourceExhausted, "rate limited")) + return + } locale := store.LocaleFromContext(ctx) var params struct { SenderID string `json:"sender_id"` diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go index 8a3092f04..52ce4bf9e 100644 --- a/internal/gateway/methods/teams_crud.go +++ b/internal/gateway/methods/teams_crud.go @@ -156,7 +156,7 @@ func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Clien return } - tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", params.Status, "", params.Channel, params.ChatID) + tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", params.Status, "", params.Channel, params.ChatID, 0) if err != nil { slog.Warn("teams.tasks.list failed", "team_id", teamID, "status_filter", params.Status, "error", err) client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go index d3e494f46..f9f389c45 100644 --- a/internal/gateway/methods/teams_workspace.go +++ b/internal/gateway/methods/teams_workspace.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -69,15 +70,26 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. return } + // Check if team uses shared workspace (no chatID scoping). + shared := false + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil { + shared = tools.IsSharedWorkspace(team.Settings) + } + baseDir := teamWorkspaceDir(m.dataDir, teamID, "") var files []workspaceFileEntry - if params.ChatID != "" { - // Scoped: list files and folders in specific chatID directory. - scopeDir := teamWorkspaceDir(m.dataDir, teamID, params.ChatID) - files = walkDir(scopeDir, "", params.ChatID) + if shared || params.ChatID != "" { + // Shared mode: list team root directly. Scoped mode: list specific chatID. + scopeDir := baseDir + scopeChatID := "" + if !shared && params.ChatID != "" { + scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID) + scopeChatID = params.ChatID + } + files = walkDir(scopeDir, "", scopeChatID) } else { - // Unscoped: list all chatID subdirectories with chatID as top-level folder. + // Isolated + unscoped: list all chatID subdirectories with chatID as top-level folder. entries, err := os.ReadDir(baseDir) if err != nil { // Directory doesn't exist = empty workspace. @@ -186,9 +198,13 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway. return } + // Shared workspace: read from team root. Isolated: require chatID. chatID := params.ChatID - if chatID == "" { - chatID = "_default" + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))) + return } scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) @@ -258,9 +274,13 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa return } + // Shared workspace: delete from team root. Isolated: require chatID. chatID := params.ChatID - if chatID == "" { - chatID = "_default" + if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))) + return } scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index dd49f7202..334b1d44e 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -142,9 +142,13 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p if ps != nil && params.SenderID != "" { paired, pairErr := ps.IsPaired(params.SenderID, "browser") if pairErr != nil { - slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr) - paired = true + // Fail-closed: deny access on DB error instead of granting operator role. + locale := i18n.Normalize(client.locale) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, + i18n.T(locale, i18n.MsgInternalError, pairErr.Error()))) + return } if paired { client.role = permissions.RoleOperator diff --git a/internal/hooks/context.go b/internal/hooks/context.go index f36a31a62..8282b4e07 100644 --- a/internal/hooks/context.go +++ b/internal/hooks/context.go @@ -7,7 +7,7 @@ type ctxKey string const skipHooksKey ctxKey = "skip_hooks" // WithSkipHooks returns a context that signals hook evaluation should be skipped. -// Used by evaluate_loop and agent evaluator to prevent recursive hook firing. +// Used by agent evaluator to prevent recursive hook firing. func WithSkipHooks(ctx context.Context, skip bool) context.Context { return context.WithValue(ctx, skipHooksKey, skip) } diff --git a/internal/http/auth.go b/internal/http/auth.go index 5c80d07ae..e7d84d3e7 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -76,6 +76,7 @@ func extractAgentID(r *http.Request, model string) string { // --- Package-level API key cache for shared auth --- var pkgAPIKeyCache *apiKeyCache +var pkgPairingStore store.PairingStore // InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation. // Must be called once during server startup before handling requests. @@ -90,6 +91,12 @@ func InitAPIKeyCache(s store.APIKeyStore, mb *bus.MessageBus) { } } +// InitPairingAuth sets the pairing store for HTTP auth. +// Allows browser-paired users to access HTTP APIs via X-GoClaw-Sender-Id header. +func InitPairingAuth(ps store.PairingStore) { + pkgPairingStore = ps +} + // ResolveAPIKey checks if the bearer token is a valid API key using the shared cache. // Returns the key data and derived role, or nil if not found/expired/revoked. func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) { @@ -123,6 +130,18 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult if _, role := ResolveAPIKey(r.Context(), bearer); role != "" { return authResult{Role: role, Authenticated: true} } + // Browser pairing → operator (via X-GoClaw-Sender-Id header) + if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { + paired, err := pkgPairingStore.IsPaired(senderID, "browser") + if err == nil && paired { + return authResult{Role: permissions.RoleOperator, Authenticated: true} + } + if err != nil { + slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err) + } else { + slog.Warn("security.http_pairing_auth_failed", "sender_id", senderID, "ip", r.RemoteAddr) + } + } // No auth configured → operator (backward compat) if gatewayToken == "" { return authResult{Role: permissions.RoleOperator, Authenticated: true} diff --git a/internal/http/providers.go b/internal/http/providers.go index 9eb69bc4a..030910a21 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -125,7 +125,7 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) case store.ProviderDashScope: - h.providerReg.Register(providers.NewDashScopeProvider(p.APIKey, p.APIBase, "")) + h.providerReg.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) case store.ProviderBailian: base := p.APIBase if base == "" { diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 346a0321a..7c1e4dd75 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -169,9 +169,6 @@ func init() { MsgToolSpawn: "Spawn a subagent for background work or delegate a task to a linked agent", MsgToolSkillSearch: "Search for available skills by keyword or description to find relevant capabilities", MsgToolUseSkill: "Activate a skill to use its specialized capabilities (tracing marker)", - MsgToolDelegateSearch: "Search for available delegation targets by keyword when there are too many linked agents to list", - MsgToolEvaluateLoop: "Run a generate→evaluate→revise loop between two agents for quality-critical output", - MsgToolHandoff: "Transfer the conversation to another agent — the user will talk directly to that agent", MsgToolTeamTasks: "View, create, update, and complete tasks on the team task board", MsgToolTeamMessage: "Send a direct message or broadcast to teammates in the agent team", }) diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 76666b17b..1322b1213 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -169,9 +169,6 @@ func init() { MsgToolSpawn: "Tạo subagent chạy nền hoặc giao việc cho agent đã liên kết", MsgToolSkillSearch: "Tìm kiếm kỹ năng có sẵn theo từ khóa hoặc mô tả", MsgToolUseSkill: "Kích hoạt kỹ năng để sử dụng khả năng chuyên biệt (đánh dấu tracing)", - MsgToolDelegateSearch: "Tìm kiếm agent mục tiêu ủy quyền theo từ khóa khi có quá nhiều agent liên kết", - MsgToolEvaluateLoop: "Chạy vòng lặp tạo→đánh giá→sửa đổi giữa hai agent cho nội dung cần chất lượng cao", - MsgToolHandoff: "Chuyển cuộc hội thoại sang agent khác — người dùng sẽ nói chuyện trực tiếp với agent đó", MsgToolTeamTasks: "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm", MsgToolTeamMessage: "Gửi tin nhắn trực tiếp hoặc broadcast đến đồng đội trong nhóm agent", }) diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 524779cb7..edfcdfefd 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -169,9 +169,6 @@ func init() { MsgToolSpawn: "创建子代理执行后台工作或将任务委派给已链接的代理", MsgToolSkillSearch: "按关键字或描述搜索可用技能以查找相关功能", MsgToolUseSkill: "激活技能以使用其专门功能(追踪标记)", - MsgToolDelegateSearch: "当链接代理过多时,按关键字搜索可用的委派目标", - MsgToolEvaluateLoop: "在两个代理之间运行生成→评估→修改循环,用于高质量输出", - MsgToolHandoff: "将对话转移给另一个代理——用户将直接与该代理交谈", MsgToolTeamTasks: "查看、创建、更新和完成团队任务板上的任务", MsgToolTeamMessage: "向代理团队中的队友发送直接消息或广播", }) diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index dd32f34bd..5ec3f0c7d 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -170,9 +170,6 @@ const ( MsgToolSpawn = "core.tool.spawn" MsgToolSkillSearch = "core.tool.skill_search" MsgToolUseSkill = "core.tool.use_skill" - MsgToolDelegateSearch = "core.tool.delegate_search" - MsgToolEvaluateLoop = "core.tool.evaluate_loop" - MsgToolHandoff = "core.tool.handoff" MsgToolTeamTasks = "core.tool.team_tasks" MsgToolTeamMessage = "core.tool.team_message" ) diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index e3eb638cc..2c75706e1 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -16,8 +16,7 @@ import ( ) // BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge. -// Excluded: spawn (agent loop), create_forum_topic (channels), -// handoff/delegate_search/evaluate_loop (require agent loop context). +// Excluded: spawn (agent loop), create_forum_topic (channels). var BridgeToolNames = map[string]bool{ // Filesystem "read_file": true, @@ -48,10 +47,8 @@ var BridgeToolNames = map[string]bool{ "sessions_history": true, "sessions_send": true, // Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers) - "team_tasks": true, - "team_message": true, - "workspace_write": true, - "workspace_read": true, + "team_tasks": true, + "team_message": true, } // NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools. diff --git a/internal/providers/dashscope.go b/internal/providers/dashscope.go index f30edc825..1bdb7072d 100644 --- a/internal/providers/dashscope.go +++ b/internal/providers/dashscope.go @@ -35,7 +35,7 @@ type DashScopeProvider struct { *OpenAIProvider } -func NewDashScopeProvider(apiKey, apiBase, defaultModel string) *DashScopeProvider { +func NewDashScopeProvider(name, apiKey, apiBase, defaultModel string) *DashScopeProvider { if apiBase == "" { apiBase = dashscopeDefaultBase } @@ -43,11 +43,11 @@ func NewDashScopeProvider(apiKey, apiBase, defaultModel string) *DashScopeProvid defaultModel = dashscopeDefaultModel } return &DashScopeProvider{ - OpenAIProvider: NewOpenAIProvider("dashscope", apiKey, apiBase, defaultModel), + OpenAIProvider: NewOpenAIProvider(name, apiKey, apiBase, defaultModel), } } -func (p *DashScopeProvider) Name() string { return "dashscope" } +// Name is inherited from the embedded OpenAIProvider (returns the user-specified name). func (p *DashScopeProvider) SupportsThinking() bool { return true } // ModelSupportsThinking implements ModelThinkingCapable. diff --git a/internal/providers/dashscope_test.go b/internal/providers/dashscope_test.go index 27a57375f..36258b97c 100644 --- a/internal/providers/dashscope_test.go +++ b/internal/providers/dashscope_test.go @@ -37,7 +37,7 @@ func newDashScopeTestServer(t *testing.T) (*httptest.Server, *map[string]any) { func callDashScopeStream(t *testing.T, req ChatRequest) map[string]any { t.Helper() server, captured := newDashScopeTestServer(t) - p := NewDashScopeProvider("test-key", server.URL, "") + p := NewDashScopeProvider("dashscope-test", "test-key", server.URL, "") p.retryConfig.Attempts = 1 p.ChatStream(context.Background(), req, nil) //nolint:errcheck return *captured @@ -45,7 +45,7 @@ func callDashScopeStream(t *testing.T, req ChatRequest) map[string]any { // TestDashScopeModelSupportsThinking verifies the whitelist is correct. func TestDashScopeModelSupportsThinking(t *testing.T) { - p := NewDashScopeProvider("key", "", "") + p := NewDashScopeProvider("dashscope", "key", "", "") tests := []struct { model string diff --git a/internal/providers/openai.go b/internal/providers/openai.go index 9fe929a01..fdb182b25 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "strings" "time" @@ -198,7 +199,10 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChun for i := 0; i < len(accumulators); i++ { acc := accumulators[i] args := make(map[string]any) - _ = json.Unmarshal([]byte(acc.rawArgs), &args) + if err := json.Unmarshal([]byte(acc.rawArgs), &args); err != nil && acc.rawArgs != "" { + slog.Warn("openai_stream: failed to parse tool call arguments", + "tool", acc.Name, "raw_len", len(acc.rawArgs), "error", err) + } acc.Arguments = args if acc.thoughtSig != "" { acc.Metadata = map[string]string{"thought_signature": acc.thoughtSig} @@ -223,7 +227,14 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream // don't return it (e.g. gemini-3-flash) will cause HTTP 400 if sent as-is. // Tool results are folded into plain user messages to preserve context. inputMessages := req.Messages - if strings.Contains(strings.ToLower(p.name), "gemini") { + + // Compute provider capability once: does this endpoint support Google's thought_signature? + // We check name, apiBase, and the model string (which covers OpenRouter/LiteLLM routing to Gemini). + supportsThoughtSignature := strings.Contains(strings.ToLower(p.name), "gemini") || + strings.Contains(strings.ToLower(p.apiBase), "generativelanguage") || + strings.Contains(strings.ToLower(model), "gemini") + + if supportsThoughtSignature { inputMessages = collapseToolCallsWithoutSig(inputMessages) } @@ -276,7 +287,11 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream "arguments": string(argsJSON), } if sig := tc.Metadata["thought_signature"]; sig != "" { - fn["thought_signature"] = sig + // Only send thought_signature to providers that support it (Google/Gemini). + // Non-Google providers will reject the unknown field with 422 Unprocessable Entity. + if supportsThoughtSignature { + fn["thought_signature"] = sig + } } toolCalls[i] = map[string]any{ "id": tc.ID, @@ -387,7 +402,10 @@ func (p *OpenAIProvider) parseResponse(resp *openAIResponse) *ChatResponse { for _, tc := range msg.ToolCalls { args := make(map[string]any) - _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil && tc.Function.Arguments != "" { + slog.Warn("openai: failed to parse tool call arguments", + "tool", tc.Function.Name, "raw_len", len(tc.Function.Arguments), "error", err) + } call := ToolCall{ ID: tc.ID, Name: strings.TrimSpace(tc.Function.Name), diff --git a/internal/store/pg/activity_store.go b/internal/store/pg/activity_store.go index ee4009207..bbb3cc0db 100644 --- a/internal/store/pg/activity_store.go +++ b/internal/store/pg/activity_store.go @@ -38,7 +38,7 @@ func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) args = append(args, limit, opts.Offset) query := fmt.Sprintf( - `SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), details, COALESCE(ip_address,''), created_at + `SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), COALESCE(details, 'null'::jsonb), COALESCE(ip_address,''), created_at FROM activity_logs %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, where, len(args)-1, len(args), ) diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go index cb6ea6fe3..dc040b3b2 100644 --- a/internal/store/pg/pairing.go +++ b/internal/store/pg/pairing.go @@ -16,6 +16,7 @@ const ( codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" codeLength = 8 codeTTL = 60 * time.Minute + pairedDeviceTTL = 30 * 24 * time.Hour // 30 days maxPendingPerAccount = 3 ) @@ -90,12 +91,13 @@ func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedD // Remove from pending s.db.Exec("DELETE FROM pairing_requests WHERE id = $1", reqID) - // Add to paired + // Add to paired (with expiry for defense-in-depth) now := time.Now() + expiresAt := now.Add(pairedDeviceTTL) _, err = s.db.Exec( - `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, + `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, ) if err != nil { return nil, fmt.Errorf("create paired device: %w", err) @@ -142,7 +144,10 @@ func (s *PGPairingStore) RevokePairing(senderID, channel string) error { func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) { var count int64 - err := s.db.QueryRow("SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2", senderID, channel).Scan(&count) + err := s.db.QueryRow( + "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND (expires_at IS NULL OR expires_at > NOW())", + senderID, channel, + ).Scan(&count) if err != nil { return false, fmt.Errorf("pairing check query: %w", err) } @@ -182,6 +187,9 @@ func (s *PGPairingStore) ListPending() []store.PairingRequestData { } func (s *PGPairingStore) ListPaired() []store.PairedDeviceData { + // Prune expired paired devices + s.db.Exec("DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()") + rows, err := s.db.Query("SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') FROM paired_devices ORDER BY paired_at DESC") if err != nil { return nil diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go index cc4ea9d12..a0911b856 100644 --- a/internal/store/pg/teams.go +++ b/internal/store/pg/teams.go @@ -267,63 +267,6 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit return users, rows.Err() } -// ============================================================ -// Handoff routing -// ============================================================ - -func (s *PGTeamStore) SetHandoffRoute(ctx context.Context, route *store.HandoffRouteData) error { - if route.ID == uuid.Nil { - route.ID = store.GenNewID() - } - route.CreatedAt = time.Now() - - var teamID *uuid.UUID - if route.TeamID != uuid.Nil { - teamID = &route.TeamID - } - - _, err := s.db.ExecContext(ctx, - `INSERT INTO handoff_routes (id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at, team_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (channel, chat_id) - DO UPDATE SET to_agent_key = EXCLUDED.to_agent_key, from_agent_key = EXCLUDED.from_agent_key, - reason = EXCLUDED.reason, created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at, - team_id = EXCLUDED.team_id`, - route.ID, route.Channel, route.ChatID, route.FromAgentKey, route.ToAgentKey, - route.Reason, route.CreatedBy, route.CreatedAt, teamID, - ) - return err -} - -func (s *PGTeamStore) GetHandoffRoute(ctx context.Context, channel, chatID string) (*store.HandoffRouteData, error) { - var d store.HandoffRouteData - var teamID *uuid.UUID - err := s.db.QueryRowContext(ctx, - `SELECT id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at, team_id - FROM handoff_routes WHERE channel = $1 AND chat_id = $2`, - channel, chatID).Scan( - &d.ID, &d.Channel, &d.ChatID, &d.FromAgentKey, &d.ToAgentKey, - &d.Reason, &d.CreatedBy, &d.CreatedAt, &teamID, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - if teamID != nil { - d.TeamID = *teamID - } - return &d, nil -} - -func (s *PGTeamStore) ClearHandoffRoute(ctx context.Context, channel, chatID string) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM handoff_routes WHERE channel = $1 AND chat_id = $2`, - channel, chatID) - return err -} - // ============================================================ // Scan helpers // ============================================================ diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 2708658a7..0854654a6 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -32,7 +32,7 @@ const taskJoinClause = `FROM team_tasks t LEFT JOIN agents ca ON ca.id = t.created_by_agent_id` // maxListTasksRows caps ListTasks results to prevent unbounded queries. -const maxListTasksRows = 50 +const maxListTasksRows = 30 // ============================================================ // Scopes @@ -163,20 +163,21 @@ func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) } -func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string) ([]store.TeamTaskData, error) { +func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, offset int) ([]store.TeamTaskData, error) { orderClause := "t.priority DESC, t.created_at" if orderBy == "newest" { orderClause = "t.created_at DESC" } - statusWhere := "AND t.status NOT IN ('completed','cancelled')" // default: active only + statusWhere := "" // default: all statuses (no filter) switch statusFilter { - case store.TeamTaskFilterAll: - statusWhere = "" + case store.TeamTaskFilterActive: + statusWhere = "AND t.status NOT IN ('completed','cancelled')" case store.TeamTaskFilterInReview: statusWhere = "AND t.status = 'in_review'" case store.TeamTaskFilterCompleted: statusWhere = "AND t.status IN ('completed','cancelled')" + // "", store.TeamTaskFilterAll ("all") → no filter (all statuses) } // Scope filter: always bind $4/$5 but only enforce when non-empty. @@ -187,7 +188,7 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s `+taskJoinClause+` WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+` ORDER BY `+orderClause+` - LIMIT $3`, teamID, userID, maxListTasksRows, channel, chatID) + LIMIT $3 OFFSET $6`, teamID, userID, maxListTasksRows+1, channel, chatID, offset) if err != nil { return nil, err } diff --git a/internal/store/pg/teams_tasks_followup.go b/internal/store/pg/teams_tasks_followup.go index 9cfaaa7e3..97a3a9cf2 100644 --- a/internal/store/pg/teams_tasks_followup.go +++ b/internal/store/pg/teams_tasks_followup.go @@ -44,19 +44,20 @@ func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) e return err } -func (s *PGTeamStore) ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) { +// ListAllFollowupDueTasks returns due followup tasks across all v2 active teams (batch). +func (s *PGTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.TeamTaskData, error) { now := time.Now() rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 - AND t.followup_at IS NOT NULL - AND t.followup_at <= $2 - AND t.status = $3 + `+v2ActiveTeamJoin+` + WHERE t.followup_at IS NOT NULL + AND t.followup_at <= $1 + AND t.status = $2 AND (t.followup_max = 0 OR t.followup_count < t.followup_max) ORDER BY t.followup_at - LIMIT 50`, - teamID, now, store.TeamTaskStatusInProgress, + LIMIT 100`, + now, store.TeamTaskStatusInProgress, ) if err != nil { return nil, err diff --git a/internal/store/pg/teams_tasks_progress.go b/internal/store/pg/teams_tasks_progress.go index 2d61dae54..4d863dfa8 100644 --- a/internal/store/pg/teams_tasks_progress.go +++ b/internal/store/pg/teams_tasks_progress.go @@ -63,49 +63,61 @@ func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUI } // ============================================================ -// Stale recovery +// Stale recovery (batch — all v2 active teams in one query) // ============================================================ -func (s *PGTeamStore) RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error) { +// v2ActiveTeamJoin is the JOIN clause that filters to v2 active teams. +const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id + AND tm.status = 'active' + AND COALESCE((tm.settings->>'version')::int, 0) >= 2` + +// RecoverAllStaleTasks resets in_progress tasks with expired locks across all v2 active teams. +func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) { now := time.Now() - res, err := s.db.ExecContext(ctx, - `UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL, - followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, - updated_at = $2 - WHERE team_id = $3 AND status = $4 AND lock_expires_at IS NOT NULL AND lock_expires_at < $2`, - store.TeamTaskStatusPending, now, - teamID, store.TeamTaskStatusInProgress, + rows, err := s.db.QueryContext(ctx, + `UPDATE team_tasks t + SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, + followup_channel = NULL, followup_chat_id = NULL, updated_at = $2 + FROM agent_teams tm + WHERE t.team_id = tm.id AND tm.status = 'active' + AND COALESCE((tm.settings->>'version')::int, 0) >= 2 + AND t.status = $3 + AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2 + RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { - return 0, err - } - n, err := res.RowsAffected() - if err != nil { - return 0, err + return nil, err } - return int(n), nil + defer rows.Close() + return scanRecoveredTaskInfoRows(rows) } -func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error) { +// ForceRecoverAllTasks resets ALL in_progress tasks across v2 active teams (startup). +func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) { now := time.Now() - res, err := s.db.ExecContext(ctx, - `UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL, - followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, - updated_at = $2 - WHERE team_id = $3 AND status = $4`, - store.TeamTaskStatusPending, now, - teamID, store.TeamTaskStatusInProgress, + rows, err := s.db.QueryContext(ctx, + `UPDATE team_tasks t + SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL, + followup_at = NULL, followup_count = 0, followup_message = NULL, + followup_channel = NULL, followup_chat_id = NULL, updated_at = $2 + FROM agent_teams tm + WHERE t.team_id = tm.id AND tm.status = 'active' + AND COALESCE((tm.settings->>'version')::int, 0) >= 2 + AND t.status = $3 + RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { - return 0, err - } - n, err := res.RowsAffected() - if err != nil { - return 0, err + return nil, err } - return int(n), nil + defer rows.Close() + return scanRecoveredTaskInfoRows(rows) } +// ListRecoverableTasks returns pending + stale-locked tasks for a single team. +// Used by DispatchUnblockedTasks after task completion. func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) { now := time.Now() rows, err := s.db.QueryContext(ctx, @@ -126,22 +138,43 @@ func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID return scanTaskRowsJoined(rows) } -func (s *PGTeamStore) MarkStaleTasks(ctx context.Context, teamID uuid.UUID, olderThan time.Time) (int, error) { +// MarkAllStaleTasks marks pending tasks older than olderThan as stale across all v2 active teams. +func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) { now := time.Now() - res, err := s.db.ExecContext(ctx, - `UPDATE team_tasks SET status = $1, updated_at = $2 - WHERE team_id = $3 AND status = $4 AND updated_at < $5`, - store.TeamTaskStatusStale, now, - teamID, store.TeamTaskStatusPending, olderThan, + rows, err := s.db.QueryContext(ctx, + `UPDATE team_tasks t + SET status = $1, updated_at = $2 + FROM agent_teams tm + WHERE t.team_id = tm.id AND tm.status = 'active' + AND COALESCE((tm.settings->>'version')::int, 0) >= 2 + AND t.status = $3 AND t.updated_at < $4 + RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan, ) if err != nil { - return 0, err + return nil, err } - n, err := res.RowsAffected() - if err != nil { - return 0, err + defer rows.Close() + return scanRecoveredTaskInfoRows(rows) +} + +func scanRecoveredTaskInfoRows(rows interface { + Next() bool + Scan(...any) error + Err() error +}) ([]store.RecoveredTaskInfo, error) { + var out []store.RecoveredTaskInfo + for rows.Next() { + var info store.RecoveredTaskInfo + if err := rows.Scan(&info.ID, &info.TeamID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { + return nil, err + } + out = append(out, info) + } + if err := rows.Err(); err != nil { + return nil, err } - return int(n), nil + return out, nil } func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error { diff --git a/internal/store/team_store.go b/internal/store/team_store.go index 5bbb59dd9..9bc83ddf1 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -9,6 +9,16 @@ import ( "github.com/google/uuid" ) +// RecoveredTaskInfo contains minimal info for leader notification after batch recovery/stale. +type RecoveredTaskInfo struct { + ID uuid.UUID + TeamID uuid.UUID + TaskNumber int + Subject string + Channel string // task's origin channel for notification routing + ChatID string // task scope for notification routing +} + // ErrFileLocked is returned when a workspace file is being written by another agent. var ErrFileLocked = errors.New("file is being written by another agent, try again shortly") @@ -42,10 +52,10 @@ const ( // Team task list filter constants (for ListTasks statusFilter parameter). const ( - TeamTaskFilterActive = "" // default: pending + in_progress + blocked + TeamTaskFilterActive = "active" // pending + in_progress + blocked TeamTaskFilterInReview = "in_review" // only in_review tasks TeamTaskFilterCompleted = "completed" // only completed tasks - TeamTaskFilterAll = "all" // all statuses + TeamTaskFilterAll = "all" // all statuses (default when "" passed) ) // Team message type constants. @@ -199,20 +209,6 @@ type DelegationHistoryListOpts struct { Offset int } -// HandoffRouteData represents an active routing override for agent handoff. -type HandoffRouteData struct { - ID uuid.UUID `json:"id"` - TeamID uuid.UUID `json:"team_id,omitempty"` - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - FromAgentKey string `json:"from_agent_key"` - ToAgentKey string `json:"to_agent_key"` - Reason string `json:"reason,omitempty"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - Metadata map[string]any `json:"metadata,omitempty"` -} - // TeamMessageData represents a message in the team mailbox. type TeamMessageData struct { ID uuid.UUID `json:"id"` @@ -320,7 +316,7 @@ type TeamStore interface { // statusFilter: "" = non-completed (default), "completed", "all". // userID: if non-empty, filter to tasks created by this user. // channel+chatID: if either is non-empty, filter to that exact scope. - ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string) ([]TeamTaskData, error) + ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, offset int) ([]TeamTaskData, error) // GetTask returns a single task by ID with joined agent info. GetTask(ctx context.Context, taskID uuid.UUID) (*TeamTaskData, error) // GetTasksByIDs returns multiple tasks by IDs in a single query. @@ -380,7 +376,8 @@ type TeamStore interface { // Follow-up reminders SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error - ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]TeamTaskData, error) + // ListAllFollowupDueTasks returns due followup tasks across all v2 active teams (batch). + ListAllFollowupDueTasks(ctx context.Context) ([]TeamTaskData, error) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error // Auto follow-up guardrails (system-level, no LLM dependency) @@ -400,15 +397,17 @@ type TeamStore interface { // Lock renewal (heartbeat to prevent stale recovery of long-running tasks) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error - // Stale recovery - RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error) + // Stale recovery (batch — all v2 active teams in single query) + // RecoverAllStaleTasks resets in_progress tasks with expired locks back to pending. + RecoverAllStaleTasks(ctx context.Context) ([]RecoveredTaskInfo, error) // ForceRecoverAllTasks resets ALL in_progress tasks back to pending (ignoring lock expiry). // Used on startup when no agents are running. - ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error) + ForceRecoverAllTasks(ctx context.Context) ([]RecoveredTaskInfo, error) // ListRecoverableTasks returns all pending tasks (including stale in_progress with expired locks). + // Per-team: used by DispatchUnblockedTasks after task completion. ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]TeamTaskData, error) - // MarkStaleTasks sets pending tasks older than olderThan to stale status. - MarkStaleTasks(ctx context.Context, teamID uuid.UUID, olderThan time.Time) (int, error) + // MarkAllStaleTasks sets pending tasks older than olderThan to stale status across all v2 active teams. + MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]RecoveredTaskInfo, error) // ResetTaskStatus resets a stale or failed task back to pending for retry. ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error @@ -417,11 +416,6 @@ type TeamStore interface { ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*DelegationHistoryData, error) - // Handoff routing - SetHandoffRoute(ctx context.Context, route *HandoffRouteData) error - GetHandoffRoute(ctx context.Context, channel, chatID string) (*HandoffRouteData, error) - ClearHandoffRoute(ctx context.Context, channel, chatID string) error - // Messages (mailbox) SendMessage(ctx context.Context, msg *TeamMessageData) error GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]TeamMessageData, error) diff --git a/internal/tasks/task_ticker.go b/internal/tasks/task_ticker.go index e1c38e20d..e9ad72c4c 100644 --- a/internal/tasks/task_ticker.go +++ b/internal/tasks/task_ticker.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "strings" "sync" "time" @@ -12,20 +13,18 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) const ( defaultRecoveryInterval = 5 * time.Minute + defaultStaleThreshold = 2 * time.Hour followupCooldown = 5 * time.Minute defaultFollowupInterval = 30 * time.Minute ) -// isTeamV2 delegates to tools.IsTeamV2 for version checking. -var isTeamV2 = tools.IsTeamV2 - // TaskTicker periodically recovers stale tasks and re-dispatches pending work. +// All recovery/stale/followup queries are batched across v2 active teams (single SQL each). type TaskTicker struct { teams store.TeamStore agents store.AgentStore @@ -92,84 +91,185 @@ func (t *TaskTicker) loop() { func (t *TaskTicker) recoverAll(forceRecover bool) { ctx := context.Background() - teams, err := t.teams.ListTeams(ctx) + // Step 1: Batch followups (before recovery — recovery resets in_progress→pending, + // which would make followup tasks invisible since followup queries status='in_progress'). + t.processFollowups(ctx) + + // Step 2: Batch recovery — single query across all v2 active teams. + var recovered []store.RecoveredTaskInfo + var err error + if forceRecover { + recovered, err = t.teams.ForceRecoverAllTasks(ctx) + } else { + recovered, err = t.teams.RecoverAllStaleTasks(ctx) + } if err != nil { - slog.Warn("task_ticker: list teams", "error", err) - return + slog.Warn("task_ticker: batch recovery", "force", forceRecover, "error", err) + } + if len(recovered) > 0 { + slog.Info("task_ticker: recovered tasks", "count", len(recovered), "force", forceRecover) + t.notifyLeaders(ctx, recovered, "recovered (lock expired)", + "These tasks were reset to pending because the assigned agent stopped responding.\n"+ + "To re-dispatch: use team_tasks(action=\"retry\", task_id=\"\") for each task above.\n"+ + "To cancel: use team_tasks(action=\"update\", task_id=\"\", status=\"cancelled\").\n"+ + "To view all tasks: use team_tasks(action=\"list\").") } - for _, team := range teams { - if team.Status != store.TeamStatusActive { - continue - } - // Skip v1 teams — ticker features (locking, followup, recovery) are v2 only. - if !isTeamV2(&team) { - continue - } - // Process followups BEFORE recovery: recovery resets in_progress→pending, - // which would make followup tasks invisible to ListFollowupDueTasks - // (it only queries status='in_progress'). - t.processFollowups(ctx, team) - t.recoverTeam(ctx, team, forceRecover) + // Step 3: Batch mark stale — pending tasks older than 2h. + staleThreshold := time.Now().Add(-defaultStaleThreshold) + stale, err := t.teams.MarkAllStaleTasks(ctx, staleThreshold) + if err != nil { + slog.Warn("task_ticker: batch mark stale", "error", err) + } + if len(stale) > 0 { + slog.Info("task_ticker: marked stale", "count", len(stale)) + t.notifyLeaders(ctx, stale, "marked stale (no progress for 2+ hours)", + "These tasks have been pending too long without being picked up.\n"+ + "To re-dispatch: use team_tasks(action=\"retry\", task_id=\"\").\n"+ + "To cancel: use team_tasks(action=\"update\", task_id=\"\", status=\"cancelled\").\n"+ + "To view current board: use team_tasks(action=\"list\").") + t.broadcastStaleEvents(ctx, stale) } - // Prune old cooldown entries to prevent memory leak. + // Step 4: Prune old cooldown entries to prevent memory leak. t.pruneCooldowns() } -func (t *TaskTicker) recoverTeam(ctx context.Context, team store.TeamData, forceRecover bool) { - // Step 1: Reset in_progress tasks back to pending. - // On startup (forceRecover=true): reset ALL in_progress — no agent is running after restart. - // On periodic tick: only reset tasks with expired locks. - var recovered int - var err error - if forceRecover { - recovered, err = t.teams.ForceRecoverAllTasks(ctx, team.ID) - } else { - recovered, err = t.teams.RecoverStaleTasks(ctx, team.ID) - } - if err != nil { - slog.Warn("task_ticker: recover tasks", "team_id", team.ID, "force", forceRecover, "error", err) +// ============================================================ +// Leader notifications (batched per scope) +// ============================================================ + +type taskScope struct { + TeamID uuid.UUID + Channel string // from task's origin channel + ChatID string +} + +// notifyLeaders sends a batched system message per (teamID, channel, chatID) scope to the leader. +func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredTaskInfo, action, hint string) { + if t.msgBus == nil { return } - if recovered > 0 { - slog.Info("task_ticker: recovered tasks", "team_id", team.ID, "count", recovered, "force", forceRecover) + + // Group by (team_id, channel, chat_id) → one message per scope. + byScope := map[taskScope][]store.RecoveredTaskInfo{} + for _, task := range tasks { + key := taskScope{TeamID: task.TeamID, Channel: task.Channel, ChatID: task.ChatID} + byScope[key] = append(byScope[key], task) } - // Step 2: Mark old pending tasks (>1 day) as stale. - // Recent pending tasks are handled by post-turn processing, not the ticker. - staleThreshold := time.Now().Add(-24 * time.Hour) - staleCount, err := t.teams.MarkStaleTasks(ctx, team.ID, staleThreshold) - if err != nil { - slog.Warn("task_ticker: mark stale", "team_id", team.ID, "error", err) - } else if staleCount > 0 { - slog.Info("task_ticker: marked stale tasks", "team_id", team.ID, "count", staleCount) - if t.msgBus != nil { - t.msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskStale, - Payload: protocol.TeamTaskEventPayload{ - TeamID: team.ID.String(), - Status: store.TeamTaskStatusStale, - Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), - ActorType: "system", - ActorID: "task_ticker", - }, - }) + // Cache team+lead lookups (same team may have multiple scopes). + teamCache := map[uuid.UUID]*store.TeamData{} + leadCache := map[uuid.UUID]*store.AgentData{} + + for scope, scopeTasks := range byScope { + team := teamCache[scope.TeamID] + if team == nil { + var err error + team, err = t.teams.GetTeam(ctx, scope.TeamID) + if err != nil { + continue + } + teamCache[scope.TeamID] = team } + lead := leadCache[team.LeadAgentID] + if lead == nil { + var err error + lead, err = t.agents.GetByID(ctx, team.LeadAgentID) + if err != nil { + continue + } + leadCache[team.LeadAgentID] = lead + } + + // Build batched task list with clear actionable hints. + var lines []string + for _, task := range scopeTasks { + lines = append(lines, fmt.Sprintf(" - Task #%d (id: %s): %s", + task.TaskNumber, task.ID, task.Subject)) + } + content := fmt.Sprintf("[System] %d task(s) %s:\n%s\n\n%s", + len(scopeTasks), action, strings.Join(lines, "\n"), hint) + + // Route using task's channel directly (from RETURNING); fallback to dashboard. + channel := scope.Channel + chatID := scope.ChatID + if channel == "" || channel == "system" || channel == "delegate" { + channel = "dashboard" + chatID = scope.TeamID.String() + } + + if !t.msgBus.TryPublishInbound(bus.InboundMessage{ + Channel: channel, + SenderID: "ticker:system", + ChatID: chatID, + AgentID: lead.AgentKey, + UserID: team.CreatedBy, + Content: content, + }) { + slog.Warn("task_ticker: inbound buffer full, notification dropped", + "team_id", scope.TeamID, "scope_chat", scope.ChatID) + } + } +} + +// broadcastStaleEvents sends UI broadcast events per team (for dashboard real-time updates). +func (t *TaskTicker) broadcastStaleEvents(ctx context.Context, tasks []store.RecoveredTaskInfo) { + if t.msgBus == nil { + return + } + // Deduplicate by team_id — one event per team. + seen := map[uuid.UUID]bool{} + for _, task := range tasks { + if seen[task.TeamID] { + continue + } + seen[task.TeamID] = true + t.msgBus.Broadcast(bus.Event{ + Name: protocol.EventTeamTaskStale, + Payload: protocol.TeamTaskEventPayload{ + TeamID: task.TeamID.String(), + Status: store.TeamTaskStatusStale, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "system", + ActorID: "task_ticker", + }, + }) } } -// processFollowups sends follow-up reminders for tasks awaiting user reply. -// Called at the end of each recoverAll cycle. -func (t *TaskTicker) processFollowups(ctx context.Context, team store.TeamData) { - tasks, err := t.teams.ListFollowupDueTasks(ctx, team.ID) +// ============================================================ +// Follow-up reminders (batch) +// ============================================================ + +func (t *TaskTicker) processFollowups(ctx context.Context) { + tasks, err := t.teams.ListAllFollowupDueTasks(ctx) if err != nil { - slog.Warn("task_ticker: list followup tasks", "team_id", team.ID, "error", err) + slog.Warn("task_ticker: list all followup tasks", "error", err) + return + } + if len(tasks) == 0 { return } + // Group by team_id for per-team interval resolution. + byTeam := map[uuid.UUID][]store.TeamTaskData{} + for _, task := range tasks { + byTeam[task.TeamID] = append(byTeam[task.TeamID], task) + } + for teamID, teamTasks := range byTeam { + team, err := t.teams.GetTeam(ctx, teamID) + if err != nil { + continue + } + interval := followupInterval(*team) + t.processTeamFollowups(ctx, teamTasks, interval) + } +} + +// processTeamFollowups sends follow-up reminders for a batch of tasks sharing the same team. +func (t *TaskTicker) processTeamFollowups(ctx context.Context, tasks []store.TeamTaskData, interval time.Duration) { now := time.Now() - interval := followupInterval(team) for i := range tasks { task := &tasks[i] @@ -224,7 +324,7 @@ func (t *TaskTicker) processFollowups(ctx context.Context, team store.TeamData) "task_number", task.TaskNumber, "count", newCount, "channel", task.FollowupChannel, - "team_id", team.ID, + "team_id", task.TeamID, ) } } diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 01c2c9c6b..79bca1004 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -224,8 +224,8 @@ func MemoryConfigFromCtx(ctx context.Context) *config.MemoryConfig { const ctxTeamID toolContextKey = "tool_team_id" -// WithToolTeamID injects the dispatching team's ID into context so workspace -// tools (workspace_read, workspace_write, team_tasks, team_message) resolve +// WithToolTeamID injects the dispatching team's ID into context so team +// tools (team_tasks, team_message) and the WorkspaceInterceptor resolve // the correct team when the agent belongs to multiple teams. func WithToolTeamID(ctx context.Context, teamID string) context.Context { return context.WithValue(ctx, ctxTeamID, teamID) @@ -237,6 +237,22 @@ func ToolTeamIDFromCtx(ctx context.Context) string { return v } +// --- Team workspace path (accessible but not default) --- + +const ctxTeamWorkspace toolContextKey = "tool_team_workspace" + +// WithToolTeamWorkspace stores the team shared workspace directory path. +// File tools allow access to this path even when restrict_to_workspace is true. +func WithToolTeamWorkspace(ctx context.Context, dir string) context.Context { + return context.WithValue(ctx, ctxTeamWorkspace, dir) +} + +// ToolTeamWorkspaceFromCtx returns the team shared workspace directory path. +func ToolTeamWorkspaceFromCtx(ctx context.Context) string { + v, _ := ctx.Value(ctxTeamWorkspace).(string) + return v +} + // --- Team task ID propagation (delegation origin → workspace tools) --- const ctxTeamTaskID toolContextKey = "tool_team_task_id" @@ -286,8 +302,10 @@ const ctxPendingDispatch toolContextKey = "tool_pending_team_dispatch" // After the turn ends, the consumer drains and dispatches them. // Thread-safe: tools may execute in parallel goroutines. type PendingTeamDispatch struct { - mu sync.Mutex - tasks map[uuid.UUID][]uuid.UUID // teamID → []taskID + mu sync.Mutex + tasks map[uuid.UUID][]uuid.UUID // teamID → []taskID + listed bool // true after list called in this turn + teamLock *sync.Mutex // acquired on list, released before post-turn dispatch } func NewPendingTeamDispatch() *PendingTeamDispatch { @@ -310,6 +328,37 @@ func (p *PendingTeamDispatch) Drain() map[uuid.UUID][]uuid.UUID { return out } +// MarkListed records that list was called in this turn. +func (p *PendingTeamDispatch) MarkListed() { + p.mu.Lock() + p.listed = true + p.mu.Unlock() +} + +// HasListed reports whether list was called in this turn. +func (p *PendingTeamDispatch) HasListed() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.listed +} + +// SetTeamLock stores the acquired team create lock so it can be released post-turn. +func (p *PendingTeamDispatch) SetTeamLock(m *sync.Mutex) { + p.mu.Lock() + p.teamLock = m + p.mu.Unlock() +} + +// ReleaseTeamLock releases the held team create lock, if any. +func (p *PendingTeamDispatch) ReleaseTeamLock() { + p.mu.Lock() + if p.teamLock != nil { + p.teamLock.Unlock() + p.teamLock = nil + } + p.mu.Unlock() +} + func WithPendingTeamDispatch(ctx context.Context, ptd *PendingTeamDispatch) context.Context { return context.WithValue(ctx, ctxPendingDispatch, ptd) } diff --git a/internal/tools/create_audio.go b/internal/tools/create_audio.go index 61494e47d..90e8d335d 100644 --- a/internal/tools/create_audio.go +++ b/internal/tools/create_audio.go @@ -158,7 +158,13 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res return ErrorResult(fmt.Sprintf("failed to save generated audio: %v", err)) } - slog.Info("create_audio: audio saved", "path", audioPath, "provider", providerName, "type", audioType) + // Verify file was persisted. + if fi, err := os.Stat(audioPath); err != nil { + slog.Warn("create_audio: file missing immediately after write", "path", audioPath, "error", err) + return ErrorResult(fmt.Sprintf("generated audio file missing after write: %v", err)) + } else { + slog.Info("create_audio: file saved", "path", audioPath, "size", fi.Size(), "data_len", len(audioBytes), "provider", providerName, "type", audioType) + } result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", audioPath)} result.Media = []bus.MediaFile{{Path: audioPath, MimeType: "audio/mpeg"}} diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index 4b79dfab6..fa6b111e2 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -95,6 +95,10 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res return ErrorResult(fmt.Sprintf("image generation failed: %v", err)) } + if len(chainResult.Data) == 0 { + return ErrorResult("image generation returned empty data") + } + // Save to workspace under date-based folder (e.g. generated/2026-03-02/) workspace := ToolWorkspaceFromCtx(ctx) if workspace == "" { @@ -109,6 +113,14 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res return ErrorResult(fmt.Sprintf("failed to save generated image: %v", err)) } + // Verify file was persisted (diagnostic for disappearing files). + if fi, err := os.Stat(imagePath); err != nil { + slog.Warn("create_image: file missing immediately after write", "path", imagePath, "error", err) + return ErrorResult(fmt.Sprintf("generated image file missing after write: %v", err)) + } else { + slog.Info("create_image: file saved", "path", imagePath, "size", fi.Size(), "data_len", len(chainResult.Data)) + } + result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", imagePath)} result.Media = []bus.MediaFile{{Path: imagePath, MimeType: "image/png"}} result.Deliverable = fmt.Sprintf("[Generated image: %s]\nPrompt: %s", filepath.Base(imagePath), prompt) diff --git a/internal/tools/create_video.go b/internal/tools/create_video.go index 7a057b562..4423596bd 100644 --- a/internal/tools/create_video.go +++ b/internal/tools/create_video.go @@ -113,6 +113,10 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res return ErrorResult(fmt.Sprintf("video generation failed: %v", err)) } + if len(chainResult.Data) == 0 { + return ErrorResult("video generation returned empty data") + } + // Save to workspace under date-based folder. workspace := ToolWorkspaceFromCtx(ctx) if workspace == "" { @@ -127,6 +131,14 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res return ErrorResult(fmt.Sprintf("failed to save generated video: %v", err)) } + // Verify file was persisted. + if fi, err := os.Stat(videoPath); err != nil { + slog.Warn("create_video: file missing immediately after write", "path", videoPath, "error", err) + return ErrorResult(fmt.Sprintf("generated video file missing after write: %v", err)) + } else { + slog.Info("create_video: file saved", "path", videoPath, "size", fi.Size(), "data_len", len(chainResult.Data)) + } + result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", videoPath)} result.Media = []bus.MediaFile{{Path: videoPath, MimeType: "video/mp4"}} result.Deliverable = fmt.Sprintf("[Generated video: %s]\nPrompt: %s", filepath.Base(videoPath), prompt) diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 4807af190..ddbff5dbc 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -160,7 +160,8 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result { if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) + allowed := allowedWithTeamWorkspace(ctx, nil) + resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index 64f511962..18d8f3298 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -145,7 +145,8 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result if workspace == "" { workspace = t.workspace } - resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), t.allowedPrefixes) + allowed := allowedWithTeamWorkspace(ctx, t.allowedPrefixes) + resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed) if err != nil { return ErrorResult(err.Error()) } @@ -155,7 +156,13 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result data, err := os.ReadFile(resolved) if err != nil { - return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + msg := fmt.Sprintf("failed to read file: %v", err) + if os.IsNotExist(err) { + if teamWs := ToolTeamWorkspaceFromCtx(ctx); teamWs != "" && !strings.HasPrefix(resolved, teamWs) { + msg += fmt.Sprintf("\nHint: file may be in the team workspace. Try: read_file(path=\"%s/%s\")", teamWs, path) + } + } + return ErrorResult(msg) } return SilentResult(string(data)) @@ -183,6 +190,19 @@ func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*san return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil } +// allowedWithTeamWorkspace returns the allowed prefixes with team workspace appended +// if present in context. Thread-safe: creates a new slice per request. +func allowedWithTeamWorkspace(ctx context.Context, base []string) []string { + teamWs := ToolTeamWorkspaceFromCtx(ctx) + if teamWs == "" { + return base + } + out := make([]string, len(base)+1) + copy(out, base) + out[len(base)] = teamWs + return out +} + // resolvePathWithAllowed is like resolvePath but also allows paths under extra prefixes. func resolvePathWithAllowed(path, workspace string, restrict bool, allowedPrefixes []string) (string, error) { resolved, err := resolvePath(path, workspace, restrict) diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go index c847f62d0..d96414db2 100644 --- a/internal/tools/filesystem_list.go +++ b/internal/tools/filesystem_list.go @@ -88,7 +88,8 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) + allowed := allowedWithTeamWorkspace(ctx, nil) + resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed) if err != nil { return ErrorResult(err.Error()) } @@ -99,7 +100,11 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul entries, err := os.ReadDir(resolved) if err != nil { if os.IsNotExist(err) { - return SilentResult(fmt.Sprintf("Directory does not exist: %s", path)) + msg := fmt.Sprintf("Directory does not exist: %s", path) + if teamWs := ToolTeamWorkspaceFromCtx(ctx); teamWs != "" && !strings.HasPrefix(resolved, teamWs) { + msg += fmt.Sprintf("\nHint: try the team workspace path: list_files(path=\"%s/%s\")", teamWs, path) + } + return SilentResult(msg) } return ErrorResult(fmt.Sprintf("failed to list directory: %v", err)) } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index cfbc7179d..973e45f8b 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -20,6 +20,7 @@ type WriteFileTool struct { contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing memIntc *MemoryInterceptor // nil = no memory routing groupWriterCache *store.GroupWriterCache // nil = no group write restriction + workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation } // DenyPaths adds path prefixes that write_file must reject. @@ -42,6 +43,11 @@ func (t *WriteFileTool) SetGroupWriterCache(c *store.GroupWriterCache) { t.groupWriterCache = c } +// SetWorkspaceInterceptor enables team workspace validation and event broadcasting. +func (t *WriteFileTool) SetWorkspaceInterceptor(intc *WorkspaceInterceptor) { + t.workspaceIntc = intc +} + func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { return &WriteFileTool{workspace: workspace, restrict: restrict} } @@ -128,7 +134,8 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) + allowed := allowedWithTeamWorkspace(ctx, nil) + resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed) if err != nil { return ErrorResult(err.Error()) } @@ -136,6 +143,21 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul return ErrorResult(err.Error()) } + // Team workspace validation + delete-on-empty. + if t.workspaceIntc != nil { + isDelete, intcErr := t.workspaceIntc.HandleWrite(ctx, resolved, content) + if intcErr != nil { + return ErrorResult(intcErr.Error()) + } + if isDelete { + if err := os.Remove(resolved); err != nil && !os.IsNotExist(err) { + return ErrorResult(fmt.Sprintf("failed to delete file: %v", err)) + } + t.workspaceIntc.AfterWrite(ctx, resolved, "delete") + return SilentResult(fmt.Sprintf("File deleted: %s", path)) + } + } + if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil { return ErrorResult(fmt.Sprintf("failed to create directory: %v", err)) } @@ -144,6 +166,10 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } + if t.workspaceIntc != nil { + t.workspaceIntc.AfterWrite(ctx, resolved, "write") + } + result := SilentResult(fmt.Sprintf("File written: %s (%d bytes)", path, len(content))) result.Deliverable = content if deliver { diff --git a/internal/tools/policy.go b/internal/tools/policy.go index 1cc2436e7..287fd6f22 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -18,7 +18,7 @@ var toolGroups = map[string][]string{ "ui": {"browser"}, "automation": {"cron"}, "messaging": {"message", "create_forum_topic"}, - "team": {"team_tasks", "team_message", "workspace_write", "workspace_read"}, + "team": {"team_tasks", "team_message"}, // Composite group: all goclaw native tools (excludes MCP/custom plugins). "goclaw": { "read_file", "write_file", "list_files", "edit", "exec", @@ -29,7 +29,7 @@ var toolGroups = map[string][]string{ "read_image", "read_document", "read_audio", "read_video", "create_image", "create_video", "skill_search", "mcp_tool_search", "tts", - "team_tasks", "team_message", "workspace_write", "workspace_read", + "team_tasks", "team_message", }, } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 95c789f5e..ef383e1de 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -2,8 +2,10 @@ package tools import ( "context" + "fmt" "log/slog" "maps" + "strings" "sync" "time" @@ -136,6 +138,21 @@ func (r *Registry) ExecuteWithContext(ctx context.Context, name string, args map } } + // Detect empty tool call arguments — typically caused by providers truncating + // or dropping arguments when output is too large (e.g. DashScope with long content). + // Give the model an actionable hint instead of a confusing "X is required" error. + if len(args) == 0 { + if params := tool.Parameters(); params != nil { + if req, ok := params["required"].([]string); ok && len(req) > 0 { + return ErrorResult(fmt.Sprintf( + "Tool call had empty arguments (required: %s). "+ + "This usually means your previous response was too long for the API to include tool parameters. "+ + "Try again with shorter content — split into smaller parts if needed.", + strings.Join(req, ", "))) + } + } + } + start := time.Now() result := tool.Execute(ctx, args) duration := time.Since(start) diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index ccb764ff8..aaff8b759 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -153,8 +153,6 @@ var SubagentDenyAlways = []string{ "memory_search", "memory_get", "sessions_send", - "workspace_write", - "workspace_read", } // SubagentDenyLeaf is the additional deny list for subagents at max depth. diff --git a/internal/tools/team_access_policy.go b/internal/tools/team_access_policy.go index 2556c11b6..0e7554724 100644 --- a/internal/tools/team_access_policy.go +++ b/internal/tools/team_access_policy.go @@ -14,8 +14,8 @@ type teamAccessSettings struct { DenyUserIDs []string `json:"deny_user_ids"` AllowChannels []string `json:"allow_channels"` DenyChannels []string `json:"deny_channels"` - ProgressNotifications *bool `json:"progress_notifications,omitempty"` - FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"` + Notifications *TeamNotifyConfig `json:"notifications,omitempty"` + FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"` FollowupMaxReminders *int `json:"followup_max_reminders,omitempty"` EscalationMode string `json:"escalation_mode,omitempty"` EscalationActions []string `json:"escalation_actions,omitempty"` diff --git a/internal/tools/team_message_tool.go b/internal/tools/team_message_tool.go index e1e13f272..d6024b28c 100644 --- a/internal/tools/team_message_tool.go +++ b/internal/tools/team_message_tool.go @@ -193,7 +193,7 @@ func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any) // Real-time delivery via message bus fromKey := t.manager.agentKeyFromID(ctx, agentID) - t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, ctx) + t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, team.Settings, ctx) preview := text if len(preview) > 100 { @@ -249,7 +249,7 @@ func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string] if m.AgentID == agentID { continue // don't send to self } - t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, ctx) + t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, team.Settings, ctx) } } @@ -302,7 +302,7 @@ func (t *TeamMessageTool) executeRead(ctx context.Context) *Result { // 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, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, ctx context.Context) { +func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, teamSettings json.RawMessage, ctx context.Context) { if t.manager.msgBus == nil { return } @@ -337,7 +337,11 @@ func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, me teamMeta["team_task_id"] = teamTaskID.String() } // Pass team workspace so the receiving agent can access shared files. - if ws, err := workspaceDir(t.manager.dataDir, teamID, "", chatID); err == nil { + wsChat := chatID + if IsSharedWorkspace(teamSettings) { + wsChat = "" + } + if ws, err := WorkspaceDir(t.manager.dataDir, teamID, wsChat); err == nil { teamMeta["team_workspace"] = ws } // Propagate trace context so the receiving agent's trace links back. diff --git a/internal/tools/team_notify_config.go b/internal/tools/team_notify_config.go new file mode 100644 index 000000000..29921a26d --- /dev/null +++ b/internal/tools/team_notify_config.go @@ -0,0 +1,44 @@ +package tools + +import "encoding/json" + +// TeamNotifyConfig controls which team task events are forwarded to chat channels. +type TeamNotifyConfig struct { + Dispatched bool `json:"dispatched"` // task assigned to member + Progress bool `json:"progress"` // member updates progress + Failed bool `json:"failed"` // task failed + Mode string `json:"mode"` // "direct" (outbound) or "leader" (through leader agent) +} + +// DefaultTeamNotifyConfig returns the default notification config. +func DefaultTeamNotifyConfig() TeamNotifyConfig { + return TeamNotifyConfig{ + Dispatched: true, + Progress: true, + Failed: true, + Mode: "direct", + } +} + +// ParseTeamNotifyConfig extracts notification config from team settings JSON. +// Returns defaults for missing/invalid settings. +func ParseTeamNotifyConfig(settings json.RawMessage) TeamNotifyConfig { + cfg := DefaultTeamNotifyConfig() + if len(settings) == 0 { + return cfg + } + var s struct { + Notifications *TeamNotifyConfig `json:"notifications"` + } + if json.Unmarshal(settings, &s) != nil || s.Notifications == nil { + return cfg + } + n := s.Notifications + cfg.Dispatched = n.Dispatched + cfg.Progress = n.Progress + cfg.Failed = n.Failed + if n.Mode == "leader" { + cfg.Mode = "leader" + } + return cfg +} diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index b435a7588..762605912 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -87,10 +87,11 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any ActorID: ownerKey, }) - // Immediately dispatch any newly-unblocked tasks. - t.manager.DispatchUnblockedTasks(ctx, team.ID) + // Dependent tasks are dispatched by the consumer after this agent's turn ends + // (post-turn), not mid-turn. This prevents dependent tasks from completing and + // announcing to the leader before this agent's own run finishes. - return NewResult(fmt.Sprintf("Task %s completed. Dependent tasks have been unblocked.", taskID)) + return NewResult(fmt.Sprintf("Task %s completed. Dependent tasks will be dispatched after this turn ends.", taskID)) } func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result { @@ -135,10 +136,9 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) - // Immediately dispatch any newly-unblocked tasks. - t.manager.DispatchUnblockedTasks(ctx, team.ID) + // Dependent tasks are dispatched by the consumer after this agent's turn ends (post-turn). - return NewResult(fmt.Sprintf("Task %s cancelled. Any running delegation has been stopped and dependent tasks unblocked.", taskID)) + return NewResult(fmt.Sprintf("Task %s cancelled. Dependent tasks will be unblocked after this turn ends.", taskID)) } func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) *Result { diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index a700c469f..8909f0e66 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -22,6 +22,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) return ErrorResult(err.Error()) } + // Gate: must list tasks before creating to prevent duplicates in concurrent group chat. + if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() { + return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"list\") to review the current task board before creating new tasks — this prevents duplicates in concurrent sessions.") + } + subject, _ := args["subject"].(string) if subject == "" { return ErrorResult("subject is required for create action") @@ -65,29 +70,29 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) } } - // Resolve optional assignee (agent key → UUID). Must be a team member. - var assigneeID uuid.UUID - if assigneeKey, _ := args["assignee"].(string); assigneeKey != "" { - aid, err := t.manager.resolveAgentByKey(assigneeKey) - if err != nil { - return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err)) - } - // Verify assignee is a member of this team. - members, err := t.manager.cachedListMembers(ctx, team.ID, agentID) - if err != nil { - return ErrorResult("failed to verify team membership: " + err.Error()) - } - isMember := false - for _, m := range members { - if m.AgentID == aid { - isMember = true - break - } - } - if !isMember { - return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey)) + // Resolve assignee (agent key → UUID). Required — every task must be assigned. + assigneeKey, _ := args["assignee"].(string) + if assigneeKey == "" { + return ErrorResult("assignee is required — specify which team member should handle this task") + } + assigneeID, err := t.manager.resolveAgentByKey(assigneeKey) + if err != nil { + return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err)) + } + // Verify assignee is a member of this team. + members, err := t.manager.cachedListMembers(ctx, team.ID, agentID) + if err != nil { + return ErrorResult("failed to verify team membership: " + err.Error()) + } + isMember := false + for _, m := range members { + if m.AgentID == assigneeID { + isMember = true + break } - assigneeID = aid + } + if !isMember { + return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey)) } requireApproval, _ := args["require_approval"].(bool) @@ -102,11 +107,16 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) chatID := ToolChatIDFromCtx(ctx) + // Shared workspace: scope by teamID only. Isolated (default): scope by chatID too. + wsChat := chatID + if IsSharedWorkspace(team.Settings) { + wsChat = "" + } + // Compute the team workspace directory so member agents write files to the - // shared team folder (teams/{teamID}/{chatID}/) instead of their own personal workspace. - // This aligns write_file/create_image with workspace_read/workspace_write paths. + // shared team folder instead of their own personal workspace. taskMeta := make(map[string]any) - if teamWsDir, err := workspaceDir(t.manager.dataDir, team.ID, "", chatID); err == nil { + if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil { taskMeta["team_workspace"] = teamWsDir } // Preserve original blocked_by list for blocker-result forwarding when task unblocks. @@ -147,9 +157,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) ChatID: chatID, Metadata: taskMeta, } - if assigneeID != uuid.Nil { - task.OwnerAgentID = &assigneeID - } + task.OwnerAgentID = &assigneeID if err := t.manager.teamStore.CreateTask(ctx, task); err != nil { return ErrorResult("failed to create task: " + err.Error()) @@ -168,23 +176,23 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) ActorType: "agent", ActorID: agentKey, }) - if assigneeID != uuid.Nil { - t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ - TeamID: team.ID.String(), - TaskID: task.ID.String(), - Status: status, - OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID), - UserID: store.UserIDFromContext(ctx), - Channel: ToolChannelFromCtx(ctx), - ChatID: chatID, - Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), - ActorType: "agent", - ActorID: agentKey, - }) - } + t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ + TeamID: team.ID.String(), + TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, + Status: status, + OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID), + UserID: store.UserIDFromContext(ctx), + Channel: ToolChannelFromCtx(ctx), + ChatID: chatID, + Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "agent", + ActorID: agentKey, + }) // Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately. - if assigneeID != uuid.Nil && status == store.TeamTaskStatusPending { + if status == store.TeamTaskStatusPending { if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil { ptd.Add(team.ID, task.ID) } else { @@ -195,8 +203,12 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID), + Channel: task.Channel, + ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "fallback_dispatch", diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go index 86abb3e76..2b9d78e0b 100644 --- a/internal/tools/team_tasks_read.go +++ b/internal/tools/team_tasks_read.go @@ -4,13 +4,145 @@ import ( "context" "encoding/json" "fmt" + "sync" + "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) -const listTasksLimit = 20 +const listPageSize = 30 + +// blockerSummary is a compact view of a blocker task for blocked_by resolution. +type blockerSummary struct { + ID uuid.UUID `json:"id"` + Subject string `json:"subject"` + Status string `json:"status"` + OwnerAgentKey string `json:"owner_agent_key,omitempty"` +} + +// taskListItem is the slim view returned by list/search actions. +type taskListItem struct { + ID uuid.UUID `json:"id"` + TaskNumber int `json:"task_number"` + Identifier string `json:"identifier"` + Subject string `json:"subject"` + Status string `json:"status"` + OwnerAgentID *uuid.UUID `json:"owner_agent_id,omitempty"` + OwnerAgentKey string `json:"owner_agent_key,omitempty"` + CreatedByAgentID *uuid.UUID `json:"created_by_agent_id,omitempty"` + CreatedByAgentKey string `json:"created_by_agent_key,omitempty"` + ProgressPercent int `json:"progress_percent,omitempty"` + ProgressStep string `json:"progress_step,omitempty"` + BlockedBy []blockerSummary `json:"blocked_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// taskDetailItem is the slim view returned by the get action. +type taskDetailItem struct { + ID uuid.UUID `json:"id"` + TaskNumber int `json:"task_number"` + Identifier string `json:"identifier"` + Subject string `json:"subject"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Result *string `json:"result,omitempty"` + OwnerAgentID *uuid.UUID `json:"owner_agent_id,omitempty"` + OwnerAgentKey string `json:"owner_agent_key,omitempty"` + CreatedByAgentID *uuid.UUID `json:"created_by_agent_id,omitempty"` + CreatedByAgentKey string `json:"created_by_agent_key,omitempty"` + ProgressPercent int `json:"progress_percent,omitempty"` + ProgressStep string `json:"progress_step,omitempty"` + BlockedBy []blockerSummary `json:"blocked_by,omitempty"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// slimComment is the slim comment view for get response. +type slimComment struct { + AgentKey string `json:"agent_key"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// slimEvent is the slim event view for get response. +type slimEvent struct { + EventType string `json:"event_type"` + ActorID string `json:"actor_id"` + CreatedAt time.Time `json:"created_at"` +} + +// teamCreateLocks serializes list→create flows per (teamID:chatID) pair. +var teamCreateLocks sync.Map // key: "teamID:chatID" → *sync.Mutex + +func getTeamCreateLock(teamID, chatID string) *sync.Mutex { + key := teamID + ":" + chatID + v, _ := teamCreateLocks.LoadOrStore(key, &sync.Mutex{}) + return v.(*sync.Mutex) +} + +// resolveBlockers batch-loads blocker tasks and returns slim summaries. +func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UUID) []blockerSummary { + if len(blockedBy) == 0 { + return nil + } + tasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy) + if err != nil { + return nil + } + out := make([]blockerSummary, 0, len(tasks)) + for _, task := range tasks { + out = append(out, blockerSummary{ + ID: task.ID, + Subject: task.Subject, + Status: task.Status, + OwnerAgentKey: task.OwnerAgentKey, + }) + } + return out +} + +func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData) taskListItem { + return taskListItem{ + ID: task.ID, + TaskNumber: task.TaskNumber, + Identifier: task.Identifier, + Subject: task.Subject, + Status: task.Status, + OwnerAgentID: task.OwnerAgentID, + OwnerAgentKey: task.OwnerAgentKey, + CreatedByAgentID: task.CreatedByAgentID, + CreatedByAgentKey: task.CreatedByAgentKey, + ProgressPercent: task.ProgressPercent, + ProgressStep: task.ProgressStep, + BlockedBy: t.resolveBlockers(ctx, task.BlockedBy), + CreatedAt: task.CreatedAt, + } +} + +func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskData) taskDetailItem { + return taskDetailItem{ + ID: task.ID, + TaskNumber: task.TaskNumber, + Identifier: task.Identifier, + Subject: task.Subject, + Description: task.Description, + Status: task.Status, + Result: task.Result, + OwnerAgentID: task.OwnerAgentID, + OwnerAgentKey: task.OwnerAgentKey, + CreatedByAgentID: task.CreatedByAgentID, + CreatedByAgentKey: task.CreatedByAgentKey, + ProgressPercent: task.ProgressPercent, + ProgressStep: task.ProgressStep, + BlockedBy: t.resolveBlockers(ctx, task.BlockedBy), + Priority: task.Priority, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } +} func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result { team, _, err := t.manager.resolveTeam(ctx) @@ -20,34 +152,54 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R statusFilter, _ := args["status"].(string) + page := 1 + if p, ok := args["page"].(float64); ok && int(p) > 1 { + page = int(p) + } + offset := (page - 1) * listPageSize + // Delegate/system channels see all tasks; end users only see their own. filterUserID := "" channel := ToolChannelFromCtx(ctx) if channel != ChannelDelegate && channel != ChannelSystem { filterUserID = store.UserIDFromContext(ctx) } + chatID := ToolChatIDFromCtx(ctx) + // Shared workspace: show all tasks across chats. + listChatID := chatID + if IsSharedWorkspace(team.Settings) { + listChatID = "" + } + + // Acquire team create lock to serialize list→create flows across concurrent goroutines. + if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() { + lock := getTeamCreateLock(team.ID.String(), chatID) + lock.Lock() + ptd.SetTeamLock(lock) + ptd.MarkListed() + } - tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", "") + tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, offset) if err != nil { return ErrorResult("failed to list tasks: " + err.Error()) } - // Strip results from list view — use action=get for full detail - for i := range tasks { - tasks[i].Result = nil + hasMore := len(tasks) > listPageSize + if hasMore { + tasks = tasks[:listPageSize] } - hasMore := len(tasks) > listTasksLimit - if hasMore { - tasks = tasks[:listTasksLimit] + items := make([]taskListItem, 0, len(tasks)) + for _, task := range tasks { + items = append(items, t.toListItem(ctx, task)) } resp := map[string]any{ - "tasks": tasks, - "count": len(tasks), + "tasks": items, + "count": len(items), + "page": page, } if hasMore { - resp["note"] = fmt.Sprintf("Showing first %d tasks. Use action=search with a query to find older tasks.", listTasksLimit) resp["has_more"] = true } @@ -100,7 +252,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re return ErrorResult("task does not belong to your team") } - // Truncate result for context protection (full result in DB) + // Truncate result for context protection const maxResultRunes = 8000 if task.Result != nil { r := []rune(*task.Result) @@ -110,21 +262,40 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re } } - // Load comments, events, and attachments for full detail view. - comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID) - events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID) - attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID) + detail := t.toDetailItem(ctx, task) - resp := map[string]any{ - "task": task, - } - if len(comments) > 0 { - resp["comments"] = comments + // Load and slim comments/events/attachments + resp := map[string]any{"task": detail} + + if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 { + slim := make([]slimComment, 0, len(comments)) + for _, c := range comments { + key := "" + if c.AgentID != nil { + key = t.manager.agentKeyFromID(ctx, *c.AgentID) + } + slim = append(slim, slimComment{ + AgentKey: key, + Content: c.Content, + CreatedAt: c.CreatedAt, + }) + } + resp["comments"] = slim } - if len(events) > 0 { - resp["events"] = events + + if events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID); len(events) > 0 { + slim := make([]slimEvent, 0, len(events)) + for _, e := range events { + slim = append(slim, slimEvent{ + EventType: e.EventType, + ActorID: e.ActorID, + CreatedAt: e.CreatedAt, + }) + } + resp["events"] = slim } - if len(attachments) > 0 { + + if attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID); len(attachments) > 0 { resp["attachments"] = attachments } @@ -150,26 +321,19 @@ func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) filterUserID = store.UserIDFromContext(ctx) } - tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, 20, filterUserID) + tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, listPageSize, filterUserID) if err != nil { return ErrorResult("failed to search tasks: " + err.Error()) } - // Show result snippets in search results - const maxSnippetRunes = 500 - for i := range tasks { - if tasks[i].Result != nil { - r := []rune(*tasks[i].Result) - if len(r) > maxSnippetRunes { - s := string(r[:maxSnippetRunes]) + "..." - tasks[i].Result = &s - } - } + items := make([]taskListItem, 0, len(tasks)) + for _, task := range tasks { + items = append(items, t.toListItem(ctx, task)) } out, _ := json.Marshal(map[string]any{ - "tasks": tasks, - "count": len(tasks), + "tasks": items, + "count": len(items), }) return SilentResult(string(out)) } diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go index a78bf1df7..4abacc1d6 100644 --- a/internal/tools/team_tasks_tool.go +++ b/internal/tools/team_tasks_tool.go @@ -54,7 +54,7 @@ func (t *TeamTasksTool) Parameters() map[string]any { }, "status": map[string]any{ "type": "string", - "description": "Filter for list: '' (active, default), 'completed', 'all'", + "description": "Filter for list: '' (all, default), 'active', 'completed', 'in_review'", }, "query": map[string]any{ "type": "string", @@ -83,7 +83,11 @@ func (t *TeamTasksTool) Parameters() map[string]any { }, "assignee": map[string]any{ "type": "string", - "description": "Agent key to assign task to (for create). Auto-dispatches to that team member.", + "description": "Agent key to assign task to (REQUIRED for create). Auto-dispatches to that team member.", + }, + "page": map[string]any{ + "type": "number", + "description": "Page number for list/search (default 1, 30 per page)", }, }, "required": []string{"action"}, diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index b12ad8f2f..74fe461fc 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -67,7 +67,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T } // Hint: tell the agent it's on a team task and where the shared workspace is. if ws := taskTeamWorkspace(task); ws != "" { - content += fmt.Sprintf("\n\n[Team workspace: %s — all files you create will be saved here, accessible by the team lead and other members via workspace_read.]", ws) + content += fmt.Sprintf("\n\n[Team workspace: %s — use read_file/write_file/list_files to access shared files. All files you write are visible to the team lead and other members.]", ws) } // Use task's stored channel/chat as primary source for routing. @@ -237,8 +237,12 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID), + Channel: task.Channel, + ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "dispatch_unblocked", diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index 069594025..9943ff510 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -94,8 +94,12 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID), + Channel: task.Channel, + ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "post_turn", @@ -144,6 +148,7 @@ func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, } // notifyLeaderCycleError sends a system message to the leader about cycled tasks. +// Uses "notification:" sender prefix to go through normal consumer flow (not handleTeammateMessage). func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uuid.UUID, cycleDesc string) { if m.msgBus == nil { return @@ -156,19 +161,23 @@ func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uui if err != nil { return } - content := fmt.Sprintf("[System] %s\nPlease recreate these tasks with corrected dependencies.", cycleDesc) + content := fmt.Sprintf("[System] %s\nPlease recreate these tasks with corrected dependencies.\nUse team_tasks(action=\"list\") to view current task board.", cycleDesc) + + // Resolve routing: use context channel/chatID if available, fallback to dashboard. + channel := ToolChannelFromCtx(ctx) + chatID := ToolChatIDFromCtx(ctx) + if channel == "" || channel == ChannelSystem || channel == ChannelDelegate { + channel = "dashboard" + chatID = teamID.String() + } + m.msgBus.TryPublishInbound(bus.InboundMessage{ - Channel: "system", - SenderID: "teammate:system", - ChatID: teamID.String(), + Channel: channel, + SenderID: "notification:system", + ChatID: chatID, AgentID: leadAgent.AgentKey, UserID: team.CreatedBy, Content: content, - Metadata: map[string]string{ - "team_id": teamID.String(), - "from_agent": leadAgent.AgentKey, - "to_agent": leadAgent.AgentKey, - }, }) } diff --git a/internal/tools/types.go b/internal/tools/types.go index 08d49e018..b177b2654 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -55,6 +55,11 @@ type GroupWriterAware interface { SetGroupWriterCache(*store.GroupWriterCache) } +// WorkspaceInterceptorAware tools can receive a WorkspaceInterceptor for team workspace validation. +type WorkspaceInterceptorAware interface { + SetWorkspaceInterceptor(*WorkspaceInterceptor) +} + // MemoryStoreAware tools can receive a MemoryStore for Postgres queries. type MemoryStoreAware interface { SetMemoryStore(store.MemoryStore) diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index 8adaca1b3..fe06ddf70 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -1,209 +1,52 @@ package tools import ( - "context" "encoding/json" "fmt" "os" "path/filepath" - "regexp" - "strings" "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" ) -// Workspace limits shared across read/write tools. -const defaultQuotaMB = 500 +// Workspace limits shared across workspace interceptor. +const ( + maxFileSizeBytes = 10 * 1024 * 1024 // 10MB + maxFilesPerScope = 100 +) -// workspaceDir returns the disk directory for a team workspace scope. -// Pattern: {dataDir}/teams/{teamID}/{chatID}/ -// chatID is the system-derived userID (stable across WS reconnects). +// WorkspaceDir returns the disk directory for a team workspace scope. +// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode) +// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode) // Creates directory with 0750 if not exists. -func workspaceDir(dataDir string, teamID uuid.UUID, _, chatID string) (string, error) { - if chatID == "" { - chatID = "_default" +func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) { + dir := filepath.Join(dataDir, "teams", teamID.String()) + if chatID != "" { + dir = filepath.Join(dir, chatID) } - dir := filepath.Join(dataDir, "teams", teamID.String(), chatID) if err := os.MkdirAll(dir, 0750); err != nil { return "", fmt.Errorf("failed to create workspace dir: %w", err) } return dir, nil } -// workspaceRelPath returns the relative path (relative to dataDir) for a workspace file. -func workspaceRelPath(teamID uuid.UUID, chatID, fileName string) string { - if chatID == "" { - chatID = "_default" - } - return filepath.Join("teams", teamID.String(), chatID, fileName) -} - -// ResolveWorkspacePath resolves a workspace file path to an absolute disk path. -// Handles both legacy absolute paths and new relative paths. -func ResolveWorkspacePath(dataDir, path string) string { - if filepath.IsAbs(path) { - return path - } - return filepath.Join(dataDir, path) -} - -// validFileName matches alphanumeric + "-_." only. -var validFileName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9._]$`) - -// sanitizeFileName validates file name: max 255 chars, no path separators, -// no null bytes, no "..", alphanumeric + "-_." only. -func sanitizeFileName(name string) (string, error) { - if name == "" { - return "", fmt.Errorf("file_name is required") - } - if len(name) > 255 { - return "", fmt.Errorf("file_name exceeds 255 characters") - } - if strings.Contains(name, "\x00") { - return "", fmt.Errorf("file_name contains null bytes") - } - if strings.Contains(name, "..") { - return "", fmt.Errorf("file_name contains path traversal") - } - if strings.ContainsAny(name, "/\\") { - return "", fmt.Errorf("file_name contains path separators") +// IsSharedWorkspace returns true if the team's workspace_scope setting is "shared". +// Default (unset or "isolated") returns false. +func IsSharedWorkspace(settings json.RawMessage) bool { + if settings == nil { + return false } - // Allow single-char names like "a" or "1" - if len(name) == 1 { - if (name[0] >= 'a' && name[0] <= 'z') || (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9') { - return name, nil - } - return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only") + var s struct { + WorkspaceScope string `json:"workspace_scope"` } - if !validFileName.MatchString(name) { - return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only") + if json.Unmarshal(settings, &s) != nil { + return false } - return name, nil + return s.WorkspaceScope == "shared" } -// blockedExtensions lists executable file types that are not allowed. +// blockedExtensions lists executable file types that are not allowed in team workspaces. var blockedExtensions = map[string]bool{ ".exe": true, ".sh": true, ".bat": true, ".cmd": true, ".ps1": true, ".com": true, ".msi": true, ".scr": true, } - -// mimeTypes maps file extensions to MIME types (package-level to avoid re-allocation). -var mimeTypes = map[string]string{ - ".txt": "text/plain", - ".md": "text/markdown", - ".json": "application/json", - ".csv": "text/csv", - ".xml": "text/xml", - ".html": "text/html", - ".css": "text/css", - ".js": "application/javascript", - ".ts": "application/typescript", - ".py": "text/x-python", - ".go": "text/x-go", - ".rs": "text/x-rust", - ".java": "text/x-java", - ".yaml": "application/x-yaml", - ".yml": "application/x-yaml", - ".toml": "application/toml", - ".sql": "application/sql", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".pdf": "application/pdf", - ".zip": "application/zip", - ".tar": "application/x-tar", - ".gz": "application/gzip", -} - -// inferMimeType returns MIME type from file extension. -// Blocks executable types. -func inferMimeType(fileName string) (string, error) { - ext := strings.ToLower(filepath.Ext(fileName)) - if blockedExtensions[ext] { - return "", fmt.Errorf("executable file type %q is not allowed", ext) - } - if mt, ok := mimeTypes[ext]; ok { - return mt, nil - } - return "application/octet-stream", nil -} - -// isBinaryMime returns true if the MIME type represents binary content. -func isBinaryMime(mimeType string) bool { - if strings.HasPrefix(mimeType, "text/") { - return false - } - switch mimeType { - case "application/json", "application/javascript", "application/typescript", - "application/x-yaml", "application/toml", "application/sql", - "application/xml": - return false - } - return true -} - -// resolveWorkspaceScope resolves the workspace scope (channel, chatID) for tools. -// Scope is (team_id, userID) where userID is the system-derived stable user ID. -// channel is always "" (kept for signature compatibility); chatID = userID. -// Priority: WorkspaceChatID context key (set during delegation) > store.UserIDFromContext -func resolveWorkspaceScope(ctx context.Context) (channel, chatID string) { - chatID = WorkspaceChatIDFromCtx(ctx) - if chatID == "" { - chatID = store.UserIDFromContext(ctx) - } - return "", chatID -} - -// workspaceSettings parses workspace-related fields from team settings JSON once. -type workspaceSettings struct { - WorkspaceScope string `json:"workspace_scope"` - WorkspaceQuotaMB *int `json:"workspace_quota_mb"` - WorkspaceTemplates []workspaceTempl `json:"workspace_templates"` -} - -type workspaceTempl struct { - FileName string `json:"file_name"` - Content string `json:"content"` -} - -func parseWorkspaceSettings(raw json.RawMessage) workspaceSettings { - var ws workspaceSettings - if len(raw) > 0 { - _ = json.Unmarshal(raw, &ws) - } - return ws -} - -func (ws workspaceSettings) isShared() bool { - return ws.WorkspaceScope == "shared" -} - -func (ws workspaceSettings) quotaMB(defaultMB int) int { - if ws.WorkspaceQuotaMB != nil { - return *ws.WorkspaceQuotaMB - } - return defaultMB -} - -// resolveWorkspaceScopeFromArgs resolves scope (channel, chatID) from team settings + context. -// Scope is determined by team config (workspace_scope=shared), not by agent args. -// When shared is enabled, chatID is cleared so all members share the same directory. -func resolveWorkspaceScopeFromArgs(ctx context.Context, _ map[string]any, ws workspaceSettings) (channel, chatID, errMsg string) { - channel, chatID = resolveWorkspaceScope(ctx) - if ws.isShared() { - chatID = "" - } - return "", chatID, "" -} - -// validTags is the set of allowed tag values. -var validTags = map[string]bool{ - "deliverable": true, - "handoff": true, - "reference": true, - "draft": true, -} diff --git a/internal/tools/workspace_interceptor.go b/internal/tools/workspace_interceptor.go new file mode 100644 index 000000000..1034f9dc0 --- /dev/null +++ b/internal/tools/workspace_interceptor.go @@ -0,0 +1,134 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// WorkspaceInterceptor validates writes and broadcasts events for team workspace files. +// When no team context is active (ToolTeamIDFromCtx returns ""), all methods are no-ops. +type WorkspaceInterceptor struct { + teamMgr *TeamToolManager +} + +func NewWorkspaceInterceptor(mgr *TeamToolManager) *WorkspaceInterceptor { + return &WorkspaceInterceptor{teamMgr: mgr} +} + +// HandleWrite validates a file write in team workspace context. +// Returns (true, nil) if the write should be treated as a delete (empty content). +// Returns (false, nil) to proceed with normal write. +// Returns (_, error) to block the write. +func (w *WorkspaceInterceptor) HandleWrite(ctx context.Context, path string, content string) (isDelete bool, err error) { + if w == nil { + return false, nil + } + teamIDStr := ToolTeamIDFromCtx(ctx) + if teamIDStr == "" { + return false, nil // Not in team context + } + + // Only apply team validation when path is inside the team workspace. + teamWs := ToolTeamWorkspaceFromCtx(ctx) + if teamWs == "" || !strings.HasPrefix(filepath.Clean(path), filepath.Clean(teamWs)) { + return false, nil // Write is to agent's own workspace, not team workspace + } + + // Resolve team and role for RBAC. Fail-open: if resolution fails (DB issue, + // corrupt cache), allow the write but log a warning for observability. + team, agentID, err := w.teamMgr.resolveTeam(ctx) + if err != nil { + slog.Warn("workspace: team resolution failed, skipping validation", "team", teamIDStr, "error", err) + return false, nil + } + role, err := w.teamMgr.resolveTeamRole(ctx, team, agentID) + if err != nil { + slog.Warn("workspace: role resolution failed, skipping validation", "team", teamIDStr, "error", err) + return false, nil + } + + // Empty content = delete. + if content == "" { + if role == store.TeamRoleReviewer { + return false, fmt.Errorf("reviewers cannot delete workspace files") + } + return true, nil + } + + // RBAC: reviewer cannot write. + if role == store.TeamRoleReviewer { + return false, fmt.Errorf("reviewers cannot write to the workspace") + } + + // Blocked extensions. + ext := strings.ToLower(filepath.Ext(path)) + if blockedExtensions[ext] { + return false, fmt.Errorf("executable file type %q is not allowed", ext) + } + + // File size limit (10MB). + if len(content) > maxFileSizeBytes { + return false, fmt.Errorf("file exceeds max size (10MB)") + } + + // Quota: count files in team workspace scope. + wsDir := teamWs + if wsDir != "" { + entries, err := os.ReadDir(wsDir) + if err != nil { + slog.Warn("workspace: quota check ReadDir failed", "dir", wsDir, "error", err) + } + fileCount := 0 + for _, e := range entries { + if !e.IsDir() { + fileCount++ + } + } + // Only check when creating new file (not updating existing). + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + if fileCount >= maxFilesPerScope { + return false, fmt.Errorf("workspace file limit reached (%d/%d)", fileCount, maxFilesPerScope) + } + } + } + + return false, nil +} + +// AfterWrite broadcasts a workspace file change event. +func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, action string) { + if w == nil { + return + } + teamIDStr := ToolTeamIDFromCtx(ctx) + if teamIDStr == "" { + return + } + // Only broadcast for writes inside team workspace. + teamWs := ToolTeamWorkspaceFromCtx(ctx) + if teamWs == "" || !strings.HasPrefix(filepath.Clean(path), filepath.Clean(teamWs)) { + return + } + + fileName := filepath.Base(path) + chatID := ToolChatIDFromCtx(ctx) + if chatID == "" { + chatID = store.UserIDFromContext(ctx) + } + + w.teamMgr.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ + "team_id": teamIDStr, + "channel": "", + "chat_id": chatID, + "file_name": fileName, + "action": action, + }) + slog.Debug("workspace: file changed", "team", teamIDStr, "file", fileName, "action", action) +} diff --git a/internal/tools/workspace_tool_read.go b/internal/tools/workspace_tool_read.go deleted file mode 100644 index a10681dc5..000000000 --- a/internal/tools/workspace_tool_read.go +++ /dev/null @@ -1,234 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// WorkspaceReadTool allows agents to read, list, and delete files in the team shared workspace. -// [DB-DISABLED] pin, tag, history, comment, comments actions are temporarily disabled (require DB). -type WorkspaceReadTool struct { - manager *TeamToolManager - dataDir string -} - -func NewWorkspaceReadTool(manager *TeamToolManager, dataDir string) *WorkspaceReadTool { - return &WorkspaceReadTool{manager: manager, dataDir: dataDir} -} - -func (t *WorkspaceReadTool) Name() string { return "workspace_read" } - -func (t *WorkspaceReadTool) Description() string { - return "Read and manage files in the team shared workspace. Actions: list, read (default), delete." -} - -func (t *WorkspaceReadTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "description": "'list', 'read' (default), 'delete'", - }, - "file_name": map[string]any{ - "type": "string", - "description": "File name (required for read and delete)", - }, - // [DB-DISABLED] These parameters require DB-backed features: - // "pinned": — pin/unpin (lead only) - // "tags": — tag files (lead only) - // "version": — read specific version - // "text": — add comment - }, - } -} - -func (t *WorkspaceReadTool) Execute(ctx context.Context, args map[string]any) *Result { - team, agentID, err := t.manager.resolveTeam(ctx) - if err != nil { - return ErrorResult(err.Error()) - } - - role, err := t.manager.resolveTeamRole(ctx, team, agentID) - if err != nil { - return ErrorResult(err.Error()) - } - - ws := parseWorkspaceSettings(team.Settings) - - // Resolve scope. - channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws) - if scopeErr != "" { - return ErrorResult(scopeErr) - } - - action, _ := args["action"].(string) - if action == "" { - action = "read" - } - - switch action { - case "list": - return t.executeList(team, channel, chatID) - case "read": - return t.executeRead(args, team, channel, chatID) - case "delete": - return t.executeDelete(args, team, agentID, role, channel, chatID) - // [DB-DISABLED] These actions require DB: - // case "pin": - // return t.executePin(ctx, args, team, role, channel, chatID) - // case "tag": - // return t.executeTag(ctx, args, team, role, channel, chatID) - // case "history": - // return t.executeHistory(ctx, args, team, channel, chatID) - // case "comment": - // return t.executeComment(ctx, args, team, agentID, channel, chatID) - // case "comments": - // return t.executeComments(ctx, args, team, channel, chatID) - default: - return ErrorResult(fmt.Sprintf("unknown action %q (use 'list', 'read', or 'delete')", action)) - } -} - -func (t *WorkspaceReadTool) executeList(team *store.TeamData, channel, chatID string) *Result { - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - entries, err := os.ReadDir(dir) - if err != nil { - return NewResult("No workspace files in this scope.") - } - - var lines []string - var totalSize int64 - for _, entry := range entries { - if entry.IsDir() { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - totalSize += info.Size() - mimeType, _ := inferMimeType(entry.Name()) - lines = append(lines, fmt.Sprintf("- %s (%s, %s)", entry.Name(), mimeType, formatBytes(info.Size()))) - } - - if len(lines) == 0 { - return NewResult("No workspace files in this scope.") - } - - const maxListFiles = 50 - header := fmt.Sprintf("Workspace path: %s\nWorkspace files (%d files, %s):\n", dir, len(lines), formatBytes(totalSize)) - footer := "\n\nTo read a file, use workspace_read(action=read, file_name=\"\") — do NOT use read_file for workspace files." - if len(lines) > maxListFiles { - result := header + strings.Join(lines[:maxListFiles], "\n") - result += fmt.Sprintf("\n\n[...truncated, showing %d of %d files. Use bash `ls %s` to see all files, or `find %s -name '*.ext'` to filter by type]", - maxListFiles, len(lines), dir, dir) - return NewResult(result + footer) - } - return NewResult(header + strings.Join(lines, "\n") + footer) -} - -func (t *WorkspaceReadTool) executeRead(args map[string]any, team *store.TeamData, channel, chatID string) *Result { - fileName, _ := args["file_name"].(string) - if fileName == "" { - return ErrorResult("file_name is required for action=read") - } - - // Sanitize to prevent path traversal. - name, err := sanitizeFileName(fileName) - if err != nil { - return ErrorResult(err.Error()) - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - diskPath := filepath.Join(dir, name) - info, err := os.Stat(diskPath) - if err != nil { - return ErrorResult(fmt.Sprintf("file %q not found in workspace — use workspace_read(action=list) to see available files", name)) - } - - mimeType, _ := inferMimeType(name) - - // Binary files: return metadata only. - if isBinaryMime(mimeType) { - return NewResult(fmt.Sprintf("Binary file: %s (%s, %s). Use other tools to process binary files.", - name, mimeType, formatBytes(info.Size()))) - } - - data, err := os.ReadFile(diskPath) - if err != nil { - return ErrorResult("failed to read file: " + err.Error()) - } - content := string(data) - if len(content) > 100000 { - content = content[:100000] + "\n\n[...truncated at 100K chars]" - } - - return NewResult(fmt.Sprintf("--- %s (%s, %s) ---\n%s", - name, mimeType, formatBytes(info.Size()), content)) -} - -func (t *WorkspaceReadTool) executeDelete(args map[string]any, team *store.TeamData, _ any, role, channel, chatID string) *Result { - fileName, _ := args["file_name"].(string) - if fileName == "" { - return ErrorResult("file_name is required for action=delete") - } - - if role == store.TeamRoleReviewer { - return ErrorResult("reviewers cannot delete workspace files") - } - - name, err := sanitizeFileName(fileName) - if err != nil { - return ErrorResult(err.Error()) - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - diskPath := filepath.Join(dir, name) - if _, err := os.Stat(diskPath); os.IsNotExist(err) { - return ErrorResult(fmt.Sprintf("file %q not found in workspace — use workspace_read(action=list) to see available files", name)) - } - - if err := os.Remove(diskPath); err != nil { - return ErrorResult("failed to delete file: " + err.Error()) - } - - // Broadcast event. - t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ - "team_id": team.ID.String(), - "channel": channel, - "chat_id": chatID, - "file_name": name, - "action": "delete", - }) - - return NewResult(fmt.Sprintf("Deleted workspace file %q", name)) -} - -// [DB-DISABLED] The following methods are temporarily disabled — they require the -// team_workspace_files DB table for metadata (pins, tags, versions, comments). -// They will be re-enabled when DB-backed workspace tracking is needed. -// -// func (t *WorkspaceReadTool) executePin(...) -// func (t *WorkspaceReadTool) executeTag(...) -// func (t *WorkspaceReadTool) executeHistory(...) -// func (t *WorkspaceReadTool) executeComment(...) -// func (t *WorkspaceReadTool) executeComments(...) diff --git a/internal/tools/workspace_tool_write.go b/internal/tools/workspace_tool_write.go deleted file mode 100644 index c14f5973d..000000000 --- a/internal/tools/workspace_tool_write.go +++ /dev/null @@ -1,339 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -const ( - maxFileSizeBytes = 10 * 1024 * 1024 // 10MB - maxFilesPerScope = 100 - maxBatchSize = 20 - // maxVersionsPerFile = 5 // [DB-DISABLED] versioning requires DB -) - -// WorkspaceWriteTool allows agents to write files to the team shared workspace. -type WorkspaceWriteTool struct { - manager *TeamToolManager - dataDir string -} - -func NewWorkspaceWriteTool(manager *TeamToolManager, dataDir string) *WorkspaceWriteTool { - return &WorkspaceWriteTool{manager: manager, dataDir: dataDir} -} - -func (t *WorkspaceWriteTool) Name() string { return "workspace_write" } - -func (t *WorkspaceWriteTool) Description() string { - return "Write files to the team shared workspace (visible to all members). Supports batch write and template management (lead only)." -} - -func (t *WorkspaceWriteTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "description": "'write' (default) or 'set_template' (lead only)", - }, - "file_name": map[string]any{ - "type": "string", - "description": "File name (alphanumeric + hyphens/underscores/dots, max 255 chars)", - }, - "content": map[string]any{ - "type": "string", - "description": "File content (text)", - }, - "files": map[string]any{ - "type": "array", - "description": "Batch write: array of {file_name, content} objects (max 20)", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "file_name": map[string]any{"type": "string"}, - "content": map[string]any{"type": "string"}, - }, - }, - }, - // [DB-DISABLED] task_id linkage requires DB - // "task_id": map[string]any{ - // "type": "string", - // "description": "Link file to a team task ID (optional)", - // }, - "templates": map[string]any{ - "type": "array", - "description": "For action=set_template: array of {file_name, content}", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "file_name": map[string]any{"type": "string"}, - "content": map[string]any{"type": "string"}, - }, - }, - }, - }, - } -} - -type writeFileEntry struct { - FileName string `json:"file_name"` - Content string `json:"content"` -} - -func (t *WorkspaceWriteTool) Execute(ctx context.Context, args map[string]any) *Result { - action, _ := args["action"].(string) - if action == "" { - action = "write" - } - - team, agentID, err := t.manager.resolveTeam(ctx) - if err != nil { - return ErrorResult(err.Error()) - } - - role, err := t.manager.resolveTeamRole(ctx, team, agentID) - if err != nil { - return ErrorResult(err.Error()) - } - - ws := parseWorkspaceSettings(team.Settings) - - switch action { - case "set_template": - return t.executeSetTemplate(ctx, args, team, role) - case "write": - return t.executeWrite(ctx, args, team, agentID, role, ws) - default: - return ErrorResult(fmt.Sprintf("unknown action %q (use 'write' or 'set_template')", action)) - } -} - -func (t *WorkspaceWriteTool) executeSetTemplate(ctx context.Context, args map[string]any, team *store.TeamData, role string) *Result { - if role != store.TeamRoleLead { - return ErrorResult("only the team lead can set workspace templates") - } - - // Check escalation policy. - if esc := t.manager.checkEscalation(team, "set_template"); esc != EscalationNone { - if esc == EscalationReject { - return ErrorResult("set_template action is not allowed by team escalation policy") - } - agentID := store.AgentIDFromContext(ctx) - return t.manager.createEscalationTask(ctx, team, agentID, - "Set workspace templates", - "Agent requested to update workspace templates.") - } - - templatesRaw, ok := args["templates"] - if !ok { - return ErrorResult("templates parameter is required for action=set_template") - } - templatesJSON, err := json.Marshal(templatesRaw) - if err != nil { - return ErrorResult("invalid templates format") - } - var templates []writeFileEntry - if err := json.Unmarshal(templatesJSON, &templates); err != nil { - return ErrorResult("templates must be array of {file_name, content}") - } - - // Validate template file names. - for _, tmpl := range templates { - if _, err := sanitizeFileName(tmpl.FileName); err != nil { - return ErrorResult(fmt.Sprintf("template %q: %s", tmpl.FileName, err)) - } - } - - // Update team settings with templates (stored in team settings JSON, not workspace DB). - var settings map[string]any - if team.Settings != nil { - _ = json.Unmarshal(team.Settings, &settings) - } - if settings == nil { - settings = make(map[string]any) - } - settings["workspace_templates"] = templates - settingsJSON, _ := json.Marshal(settings) - - if err := t.manager.teamStore.UpdateTeam(ctx, team.ID, map[string]any{"settings": settingsJSON}); err != nil { - return ErrorResult("failed to save templates: " + err.Error()) - } - t.manager.InvalidateTeam() - - return NewResult(fmt.Sprintf("Set %d workspace template(s)", len(templates))) -} - -func (t *WorkspaceWriteTool) executeWrite(ctx context.Context, args map[string]any, team *store.TeamData, _ /* agentID */ any, role string, ws workspaceSettings) *Result { - if role == store.TeamRoleReviewer { - return ErrorResult("reviewers cannot write to the workspace") - } - - // Resolve scope. - channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws) - if scopeErr != "" { - return ErrorResult(scopeErr) - } - - // [DB-DISABLED] task linkage requires DB - // var taskID *uuid.UUID - // if tid, ok := args["task_id"].(string); ok && tid != "" { - // parsed, err := uuid.Parse(tid) - // if err != nil { - // return ErrorResult("invalid task_id: " + err.Error()) - // } - // taskID = &parsed - // } else if ctxTID := TeamTaskIDFromCtx(ctx); ctxTID != "" { - // if parsed, err := uuid.Parse(ctxTID); err == nil { - // taskID = &parsed - // } - // } - - // Normalize input to batch. - var entries []writeFileEntry - if filesRaw, ok := args["files"]; ok { - filesJSON, err := json.Marshal(filesRaw) - if err != nil { - return ErrorResult("invalid files format") - } - if err := json.Unmarshal(filesJSON, &entries); err != nil { - return ErrorResult("files must be array of {file_name, content}") - } - } else { - fn, _ := args["file_name"].(string) - content, _ := args["content"].(string) - if fn == "" { - return ErrorResult("file_name is required") - } - entries = []writeFileEntry{{FileName: fn, Content: content}} - } - - if len(entries) == 0 { - return ErrorResult("no files to write") - } - if len(entries) > maxBatchSize { - return ErrorResult(fmt.Sprintf("batch size exceeds limit (%d max)", maxBatchSize)) - } - - // Validate all entries before writing. - for i, e := range entries { - name, err := sanitizeFileName(e.FileName) - if err != nil { - return ErrorResult(fmt.Sprintf("file %d: %s", i+1, err)) - } - entries[i].FileName = name - - if _, err := inferMimeType(name); err != nil { - return ErrorResult(fmt.Sprintf("file %q: %s", name, err)) - } - if len(e.Content) > maxFileSizeBytes { - return ErrorResult(fmt.Sprintf("file %q exceeds max size (10MB)", name)) - } - } - - // Create workspace directory. - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - // Check file count limit from filesystem. - existingFiles, _ := os.ReadDir(dir) - fileCount := 0 - for _, f := range existingFiles { - if !f.IsDir() { - fileCount++ - } - } - - // Auto-seed templates on first write to this scope. - if fileCount == 0 { - t.seedTemplates(team, channel, chatID, ws) - // Recount after seeding. - existingFiles, _ = os.ReadDir(dir) - fileCount = 0 - for _, f := range existingFiles { - if !f.IsDir() { - fileCount++ - } - } - } - - if fileCount+len(entries) > maxFilesPerScope { - return ErrorResult(fmt.Sprintf("workspace file limit reached (%d/%d)", fileCount, maxFilesPerScope)) - } - - // Write files directly to disk (no DB). - var results []string - var errors []string - for _, e := range entries { - diskPath := filepath.Join(dir, e.FileName) - - if err := os.WriteFile(diskPath, []byte(e.Content), 0640); err != nil { - errors = append(errors, fmt.Sprintf("%s: %s", e.FileName, err)) - continue - } - - results = append(results, fmt.Sprintf("%s (%s)", e.FileName, formatBytes(int64(len(e.Content))))) - - // Broadcast event. - t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ - "team_id": team.ID.String(), - "channel": channel, - "chat_id": chatID, - "file_name": e.FileName, - "action": "write", - }) - } - - if len(results) == 0 && len(errors) > 0 { - return ErrorResult("all writes failed:\n" + strings.Join(errors, "\n")) - } - - msg := fmt.Sprintf("Written %d file(s) to workspace: %s", len(results), strings.Join(results, ", ")) - if len(errors) > 0 { - msg += fmt.Sprintf("\n%d failed: %s", len(errors), strings.Join(errors, "; ")) - } - return NewResult(msg) -} - -func (t *WorkspaceWriteTool) seedTemplates(team *store.TeamData, channel, chatID string, ws workspaceSettings) { - if len(ws.WorkspaceTemplates) == 0 { - return - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return - } - - for _, tmpl := range ws.WorkspaceTemplates { - name, err := sanitizeFileName(tmpl.FileName) - if err != nil { - continue - } - diskPath := filepath.Join(dir, name) - if err := os.WriteFile(diskPath, []byte(tmpl.Content), 0640); err != nil { - slog.Warn("workspace: template seed failed", "file", name, "error", err) - } - } - slog.Info("workspace: seeded templates", "count", len(ws.WorkspaceTemplates), "team", team.ID, "channel", channel, "chat_id", chatID) -} - -func formatBytes(b int64) string { - switch { - case b >= 1024*1024: - return fmt.Sprintf("%.1f MB", float64(b)/(1024*1024)) - case b >= 1024: - return fmt.Sprintf("%.1f KB", float64(b)/1024) - default: - return fmt.Sprintf("%d B", b) - } -} diff --git a/internal/tracing/collector.go b/internal/tracing/collector.go index 9d66624e2..184843e1a 100644 --- a/internal/tracing/collector.go +++ b/internal/tracing/collector.go @@ -83,10 +83,10 @@ func NewCollector(ts store.TracingStore) *Collector { // Verbose returns true if verbose tracing is enabled (full LLM input logging). func (c *Collector) Verbose() bool { return c.verbose } -// PreviewMaxLen returns the max preview length: 100K when verbose, 500 otherwise. +// PreviewMaxLen returns the max preview length: 200K when verbose, 500 otherwise. func (c *Collector) PreviewMaxLen() int { if c.verbose { - return 100_000 + return 200_000 } return previewMaxLen } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 0e6b69fc4..fb56bf408 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 20 +const RequiredSchemaVersion uint = 21 diff --git a/migrations/000021_paired_devices_expiry.down.sql b/migrations/000021_paired_devices_expiry.down.sql new file mode 100644 index 000000000..71213b231 --- /dev/null +++ b/migrations/000021_paired_devices_expiry.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE paired_devices DROP COLUMN IF EXISTS expires_at; +ALTER TABLE team_tasks DROP COLUMN IF EXISTS confidence_score; +ALTER TABLE team_messages DROP COLUMN IF EXISTS confidence_score; +ALTER TABLE team_task_comments DROP COLUMN IF EXISTS confidence_score; diff --git a/migrations/000021_paired_devices_expiry.up.sql b/migrations/000021_paired_devices_expiry.up.sql new file mode 100644 index 000000000..b9a5ffafb --- /dev/null +++ b/migrations/000021_paired_devices_expiry.up.sql @@ -0,0 +1,8 @@ +-- Add expiry to paired devices for defense-in-depth. +-- NULL means no expiry (backward compat for existing rows). +ALTER TABLE paired_devices ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + +-- Add confidence_score to team tables for agent self-assessment. +ALTER TABLE team_tasks ADD COLUMN IF NOT EXISTS confidence_score FLOAT; +ALTER TABLE team_messages ADD COLUMN IF NOT EXISTS confidence_score FLOAT; +ALTER TABLE team_task_comments ADD COLUMN IF NOT EXISTS confidence_score FLOAT; diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index d47fd00af..5c6c40c00 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -22,9 +22,6 @@ const ( // Agent summoning events (predefined agent setup via LLM). EventAgentSummoning = "agent.summoning" - // Agent handoff event (payload: from_agent, to_agent, reason). - EventHandoff = "handoff" - // Team activity events (real-time team workflow visibility). EventTeamTaskCreated = "team.task.created" EventTeamTaskCompleted = "team.task.completed" diff --git a/pkg/protocol/team_events.go b/pkg/protocol/team_events.go index fe674586c..57ad438f1 100644 --- a/pkg/protocol/team_events.go +++ b/pkg/protocol/team_events.go @@ -107,6 +107,7 @@ type QualityGateRetryPayload struct { type TeamTaskEventPayload struct { TeamID string `json:"team_id"` TaskID string `json:"task_id"` + TaskNumber int `json:"task_number,omitempty"` Subject string `json:"subject,omitempty"` Status string `json:"status"` OwnerAgentKey string `json:"owner_agent_key,omitempty"` @@ -117,6 +118,10 @@ type TeamTaskEventPayload struct { ChatID string `json:"chat_id"` Timestamp string `json:"timestamp"` + // Progress (for team.task.progress events). + ProgressPercent int `json:"progress_percent,omitempty"` + ProgressStep string `json:"progress_step,omitempty"` + // Actor info for audit trail (recorded to team_task_events by subscriber). ActorType string `json:"actor_type,omitempty"` // "agent", "human", "system" ActorID string `json:"actor_id,omitempty"` // agent key, user ID, or system identifier diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index dc5a7785c..a60188596 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -7,6 +7,7 @@ export class HttpClient { private baseUrl: string, private getToken: () => string, private getUserId: () => string, + private getSenderID: () => string = () => "", ) {} async get(path: string, params?: Record): Promise { @@ -108,6 +109,8 @@ export class HttpClient { if (token) h["Authorization"] = `Bearer ${token}`; const userId = this.getUserId(); if (userId) h["X-GoClaw-User-Id"] = userId; + const senderID = this.getSenderID(); + if (senderID) h["X-GoClaw-Sender-Id"] = senderID; return h; } diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index dd5f8a1a0..a5629ee29 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -150,10 +150,6 @@ export const Methods = { TEAMS_WORKSPACE_READ: "teams.workspace.read", TEAMS_WORKSPACE_DELETE: "teams.workspace.delete", - // Delegation history - DELEGATIONS_LIST: "delegations.list", - DELEGATIONS_GET: "delegations.get", - // Phase 3+ - NICE TO HAVE LOGS_TAIL: "logs.tail", } as const; @@ -177,17 +173,6 @@ export const Events = { VOICEWAKE_CHANGED: "voicewake.changed", CONNECT_CHALLENGE: "connect.challenge", TALK_MODE: "talk.mode", - HANDOFF: "handoff", - - // Delegation lifecycle - DELEGATION_STARTED: "delegation.started", - DELEGATION_COMPLETED: "delegation.completed", - DELEGATION_FAILED: "delegation.failed", - DELEGATION_CANCELLED: "delegation.cancelled", - DELEGATION_PROGRESS: "delegation.progress", - DELEGATION_ACCUMULATED: "delegation.accumulated", - DELEGATION_ANNOUNCE: "delegation.announce", - DELEGATION_QUALITY_GATE_RETRY: "delegation.quality_gate.retry", // Team tasks TEAM_TASK_CREATED: "team.task.created", @@ -234,10 +219,6 @@ export const Events = { /** All event names relevant to team debug view */ export const TEAM_RELATED_EVENTS: Set = new Set([ - Events.DELEGATION_STARTED, Events.DELEGATION_COMPLETED, - Events.DELEGATION_FAILED, Events.DELEGATION_CANCELLED, - Events.DELEGATION_PROGRESS, Events.DELEGATION_ACCUMULATED, - Events.DELEGATION_ANNOUNCE, Events.DELEGATION_QUALITY_GATE_RETRY, Events.TEAM_TASK_CREATED, Events.TEAM_TASK_CLAIMED, Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED, Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED, diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index e7c0ec0de..eaf95c32f 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -17,7 +17,6 @@ import { Plug, Volume2, Cpu, - ArrowRightLeft, ClipboardList, HardDrive, Inbox, @@ -106,7 +105,6 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { - diff --git a/ui/web/src/components/providers/ws-provider.tsx b/ui/web/src/components/providers/ws-provider.tsx index e92cb524b..bc53ae985 100644 --- a/ui/web/src/components/providers/ws-provider.tsx +++ b/ui/web/src/components/providers/ws-provider.tsx @@ -31,7 +31,10 @@ export function WsProvider({ children }: { children: React.ReactNode }) { }, ); wsRef.current.onAuthFailure = () => { - useAuthStore.getState().logout(); + // Don't logout if authenticated via browser pairing (no token) + const state = useAuthStore.getState(); + if (state.senderID && !state.token) return; + state.logout(); }; } const ws = wsRef.current; @@ -41,9 +44,13 @@ export function WsProvider({ children }: { children: React.ReactNode }) { "", () => useAuthStore.getState().token, () => useAuthStore.getState().userId, + () => useAuthStore.getState().senderID, ); client.onAuthFailure = () => { - useAuthStore.getState().logout(); + // Don't logout if authenticated via browser pairing (no token) + const state = useAuthStore.getState(); + if (state.senderID && !state.token) return; + state.logout(); }; return client; }, []); diff --git a/ui/web/src/components/shared/require-auth.tsx b/ui/web/src/components/shared/require-auth.tsx index 89f8ce807..38c2d2f10 100644 --- a/ui/web/src/components/shared/require-auth.tsx +++ b/ui/web/src/components/shared/require-auth.tsx @@ -5,9 +5,10 @@ import { ROUTES } from "@/lib/constants"; export function RequireAuth({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); const userId = useAuthStore((s) => s.userId); + const senderID = useAuthStore((s) => s.senderID); const location = useLocation(); - if (!token || !userId) { + if ((!token && !senderID) || !userId) { return ; } diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index 52407d8b6..ff2ce1ea4 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -18,7 +18,6 @@ import enChannels from "./locales/en/channels.json"; import enProviders from "./locales/en/providers.json"; import enTraces from "./locales/en/traces.json"; import enEvents from "./locales/en/events.json"; -import enDelegations from "./locales/en/delegations.json"; import enUsage from "./locales/en/usage.json"; import enApprovals from "./locales/en/approvals.json"; import enNodes from "./locales/en/nodes.json"; @@ -52,7 +51,6 @@ import viChannels from "./locales/vi/channels.json"; import viProviders from "./locales/vi/providers.json"; import viTraces from "./locales/vi/traces.json"; import viEvents from "./locales/vi/events.json"; -import viDelegations from "./locales/vi/delegations.json"; import viUsage from "./locales/vi/usage.json"; import viApprovals from "./locales/vi/approvals.json"; import viNodes from "./locales/vi/nodes.json"; @@ -86,7 +84,6 @@ import zhChannels from "./locales/zh/channels.json"; import zhProviders from "./locales/zh/providers.json"; import zhTraces from "./locales/zh/traces.json"; import zhEvents from "./locales/zh/events.json"; -import zhDelegations from "./locales/zh/delegations.json"; import zhUsage from "./locales/zh/usage.json"; import zhApprovals from "./locales/zh/approvals.json"; import zhNodes from "./locales/zh/nodes.json"; @@ -117,7 +114,7 @@ function getInitialLanguage(): string { const ns = [ "common", "sidebar", "topbar", "login", "overview", "chat", "agents", "teams", "sessions", "skills", "cron", "config", - "channels", "providers", "traces", "events", "delegations", + "channels", "providers", "traces", "events", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", "setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys", "cli-credentials", @@ -130,7 +127,7 @@ i18n.use(initReactI18next).init({ overview: enOverview, chat: enChat, agents: enAgents, teams: enTeams, sessions: enSessions, skills: enSkills, cron: enCron, config: enConfig, channels: enChannels, providers: enProviders, traces: enTraces, - events: enEvents, delegations: enDelegations, usage: enUsage, + events: enEvents, usage: enUsage, approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools, mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage, "pending-messages": enPendingMessages, @@ -142,7 +139,7 @@ i18n.use(initReactI18next).init({ overview: viOverview, chat: viChat, agents: viAgents, teams: viTeams, sessions: viSessions, skills: viSkills, cron: viCron, config: viConfig, channels: viChannels, providers: viProviders, traces: viTraces, - events: viEvents, delegations: viDelegations, usage: viUsage, + events: viEvents, usage: viUsage, approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools, mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage, "pending-messages": viPendingMessages, @@ -154,7 +151,7 @@ i18n.use(initReactI18next).init({ overview: zhOverview, chat: zhChat, agents: zhAgents, teams: zhTeams, sessions: zhSessions, skills: zhSkills, cron: zhCron, config: zhConfig, channels: zhChannels, providers: zhProviders, traces: zhTraces, - events: zhEvents, delegations: zhDelegations, usage: zhUsage, + events: zhEvents, usage: zhUsage, approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools, mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage, "pending-messages": zhPendingMessages, diff --git a/ui/web/src/i18n/locales/en/delegations.json b/ui/web/src/i18n/locales/en/delegations.json deleted file mode 100644 index d3d6151df..000000000 --- a/ui/web/src/i18n/locales/en/delegations.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Delegations", - "description": "Agent delegation history and results", - "sourceFilterPlaceholder": "Source agent...", - "targetFilterPlaceholder": "Target agent...", - "filter": "Filter", - "emptyTitle": "No delegations", - "emptyDescription": "No delegation records found. Delegations are recorded when agents delegate tasks to other agents.", - "columns": { - "sourceTarget": "Source / Target", - "task": "Task", - "status": "Status", - "mode": "Mode", - "duration": "Duration", - "time": "Time" - }, - "statusFilter": { - "all": "All", - "pending": "Pending", - "running": "Running", - "completed": "Completed", - "failed": "Failed", - "cancelled": "Cancelled" - }, - "detail": { - "title": "Delegation Detail", - "notFound": "Delegation not found.", - "source": "Source:", - "target": "Target:", - "status": "Status:", - "mode": "Mode:", - "duration": "Duration:", - "iterations": "Iterations:", - "created": "Created:", - "completed": "Completed:", - "trace": "Trace:", - "task": "Task", - "result": "Result", - "error": "Error" - } -} diff --git a/ui/web/src/i18n/locales/en/events.json b/ui/web/src/i18n/locales/en/events.json index eac2eca6c..cfcf25a5a 100644 --- a/ui/web/src/i18n/locales/en/events.json +++ b/ui/web/src/i18n/locales/en/events.json @@ -11,7 +11,6 @@ "eventCountPlural": "{{count}} events", "categories": { "all": "All", - "delegation": "Delegation", "task": "Task", "message": "Message", "agent": "Agent", diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index 1fc8ad5ea..b92a1904c 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -26,7 +26,6 @@ "knowledgeGraph": "Knowledge Graph", "traces": "Traces", "realtimeEvents": "Realtime Events", - "delegations": "Delegations", "usage": "Usage", "logs": "Logs", "storage": "Storage", diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index aa54dd004..7a5a50c1a 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "Denied Channels", "deniedChannelsHint": "Messages from these channels are always blocked.", "notifications": "Notifications", - "progressNotifications": "Progress notifications", - "progressNotificationsHint": "Send periodic \"Your team is working on it...\" messages to chat during async delegations.", + "notifyDispatched": "Task dispatched", + "notifyDispatchedHint": "Notify when a task is assigned to a team member.", + "notifyProgress": "Task progress", + "notifyProgressHint": "Notify when a member updates task progress.", + "notifyFailed": "Task failed", + "notifyFailedHint": "Notify when a task fails.", + "notifyMode": "Delivery mode", + "notifyModeHint": "How notifications are delivered to the chat channel.", + "notifyModeDirect": "Direct", + "notifyModeDirectDesc": "Send notifications straight to the chat channel. Fast, no AI processing.", + "notifyModeLeader": "Through Leader", + "notifyModeLeaderDesc": "Leader agent reformulates updates naturally before sending to user.", + "notifyModeLeaderWarning": "Leader mode uses AI to reformulate messages (costs tokens, may be slower).", "searchUsers": "Search users...", "selectChannel": "Select channel...", "escalationPolicy": "Escalation Policy", @@ -198,12 +209,12 @@ "followupMaxReminders": "Max reminders", "followupMaxRemindersHint": "Maximum number of reminders per task. 0 = unlimited (remind until user replies).", "workspace": "Workspace", - "workspaceScope": "Workspace Sharing", - "workspaceScopeHint": "Control how agents access files in the team workspace.", + "workspaceScope": "Workspace Scope", + "workspaceScopeHint": "Control how team workspace files are organized.", "workspaceScopeIsolated": "Isolated", - "workspaceScopeIsolatedDesc": "Each agent has its own private workspace. Agents cannot read or write files from other agents.", + "workspaceScopeIsolatedDesc": "Each conversation has its own workspace folder. Files from one chat are not visible in another.", "workspaceScopeShared": "Shared", - "workspaceScopeSharedDesc": "All agents share a single team workspace. Agents can read and write each other's files.", + "workspaceScopeSharedDesc": "All conversations share a single workspace folder. Files created in any chat are visible to all.", "teamVersion": "Team Version", "versionBasic": "Basic", "versionAdvanced": "Advanced", diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index a168a1f7e..80766217d 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -183,9 +183,6 @@ "spawn": "Spawn a subagent to handle a task in the background", "skill_search": "Search for available skills by keyword or description to find relevant capabilities", "use_skill": "Activate a skill to use its specialized capabilities (tracing marker)", - "delegate_search": "Search for available delegation targets (deprecated)", - "evaluate_loop": "Run a generate→evaluate→revise loop between two agents (deprecated)", - "handoff": "Transfer the conversation to another agent (deprecated)", "team_tasks": "View, create, update, and complete tasks on the team task board", "team_message": "Send a direct message or broadcast to teammates in the agent team" } diff --git a/ui/web/src/i18n/locales/en/traces.json b/ui/web/src/i18n/locales/en/traces.json index 6db86b81d..56014d0f6 100644 --- a/ui/web/src/i18n/locales/en/traces.json +++ b/ui/web/src/i18n/locales/en/traces.json @@ -29,8 +29,10 @@ "spans": "Spans:", "started": "Started:", "delegatedFrom": "Delegated from:", + "createdAt": "Created:", "input": "Input", "output": "Output", + "copy": "Copy", "spansCount": "Spans ({{count}})", "llmCalls": "LLM", "toolCalls": "tool", @@ -39,8 +41,12 @@ "span": { "model": "Model:", "tokens": "Tokens:", + "createdAt": "Created:", "input": "Input:", "output": "Output:", + "copy": "Copy", + "startTime": "Start:", + "endTime": "End:", "cacheRead": "read", "cacheWrite": "write", "thinking": "thinking", diff --git a/ui/web/src/i18n/locales/vi/delegations.json b/ui/web/src/i18n/locales/vi/delegations.json deleted file mode 100644 index 79fc54fd9..000000000 --- a/ui/web/src/i18n/locales/vi/delegations.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Ủy quyền", - "description": "Lịch sử và kết quả ủy quyền agent", - "sourceFilterPlaceholder": "Agent nguồn...", - "targetFilterPlaceholder": "Agent đích...", - "filter": "Lọc", - "emptyTitle": "Không có ủy quyền", - "emptyDescription": "Không tìm thấy bản ghi ủy quyền. Ủy quyền được ghi lại khi agent ủy thác nhiệm vụ cho agent khác.", - "columns": { - "sourceTarget": "Nguồn / Đích", - "task": "Nhiệm vụ", - "status": "Trạng thái", - "mode": "Chế độ", - "duration": "Thời lượng", - "time": "Thời gian" - }, - "statusFilter": { - "all": "Tất cả", - "pending": "Đang chờ", - "running": "Đang chạy", - "completed": "Hoàn thành", - "failed": "Thất bại", - "cancelled": "Đã hủy" - }, - "detail": { - "title": "Chi tiết ủy quyền", - "notFound": "Không tìm thấy ủy quyền.", - "source": "Nguồn:", - "target": "Đích:", - "status": "Trạng thái:", - "mode": "Chế độ:", - "duration": "Thời lượng:", - "iterations": "Số lần lặp:", - "created": "Đã tạo:", - "completed": "Hoàn thành:", - "trace": "Theo dõi:", - "task": "Nhiệm vụ", - "result": "Kết quả", - "error": "Lỗi" - } -} diff --git a/ui/web/src/i18n/locales/vi/events.json b/ui/web/src/i18n/locales/vi/events.json index 897e3c5c2..86c1fe724 100644 --- a/ui/web/src/i18n/locales/vi/events.json +++ b/ui/web/src/i18n/locales/vi/events.json @@ -11,7 +11,6 @@ "eventCountPlural": "{{count}} sự kiện", "categories": { "all": "Tất cả", - "delegation": "Ủy quyền", "task": "Nhiệm vụ", "message": "Tin nhắn", "agent": "Agent", diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index d3425fa17..0e42735fe 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -26,7 +26,6 @@ "knowledgeGraph": "Knowledge Graph", "traces": "Theo dõi", "realtimeEvents": "Sự kiện thời gian thực", - "delegations": "Ủy quyền", "usage": "Sử dụng", "logs": "Nhật ký", "storage": "Lưu trữ", diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index c28f51390..e1b484da6 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "Channel bị từ chối", "deniedChannelsHint": "Tin nhắn từ các channel này luôn bị chặn.", "notifications": "Thông báo", - "progressNotifications": "Thông báo tiến trình", - "progressNotificationsHint": "Gửi định kỳ thông báo \"Team của bạn đang xử lý...\" trong quá trình ủy quyền bất đồng bộ.", + "notifyDispatched": "Giao nhiệm vụ", + "notifyDispatchedHint": "Thông báo khi nhiệm vụ được giao cho thành viên.", + "notifyProgress": "Tiến trình", + "notifyProgressHint": "Thông báo khi thành viên cập nhật tiến trình.", + "notifyFailed": "Thất bại", + "notifyFailedHint": "Thông báo khi nhiệm vụ thất bại.", + "notifyMode": "Chế độ gửi", + "notifyModeHint": "Cách thông báo được gửi đến kênh chat.", + "notifyModeDirect": "Trực tiếp", + "notifyModeDirectDesc": "Gửi thông báo thẳng đến kênh chat. Nhanh, không qua AI.", + "notifyModeLeader": "Qua Leader", + "notifyModeLeaderDesc": "Leader diễn đạt lại cập nhật một cách tự nhiên trước khi gửi.", + "notifyModeLeaderWarning": "Chế độ Leader dùng AI để diễn đạt lại (tốn token, có thể chậm hơn).", "searchUsers": "Tìm kiếm người dùng...", "selectChannel": "Chọn channel...", "escalationPolicy": "Chính sách Leo thang", @@ -198,12 +209,12 @@ "followupMaxReminders": "Số lần nhắc tối đa", "followupMaxRemindersHint": "Số lần nhắc tối đa cho mỗi nhiệm vụ. 0 = không giới hạn (nhắc cho đến khi người dùng trả lời).", "workspace": "Không gian làm việc", - "workspaceScope": "Chia sẻ không gian làm việc", - "workspaceScopeHint": "Kiểm soát cách các agent truy cập tệp trong không gian làm việc của team.", + "workspaceScope": "Phạm vi workspace", + "workspaceScopeHint": "Kiểm soát cách tổ chức tệp trong workspace của team.", "workspaceScopeIsolated": "Cô lập", - "workspaceScopeIsolatedDesc": "Mỗi agent có không gian làm việc riêng. Không thể đọc hoặc ghi tệp của agent khác.", + "workspaceScopeIsolatedDesc": "Mỗi cuộc hội thoại có thư mục workspace riêng. Tệp từ chat này không hiển thị ở chat khác.", "workspaceScopeShared": "Chia sẻ", - "workspaceScopeSharedDesc": "Tất cả agent dùng chung một không gian làm việc. Có thể đọc và ghi tệp của nhau.", + "workspaceScopeSharedDesc": "Tất cả cuộc hội thoại dùng chung một thư mục workspace. Tệp tạo từ bất kỳ chat nào đều hiển thị cho tất cả.", "teamVersion": "Phiên bản Team", "versionBasic": "Cơ bản", "versionAdvanced": "Nâng cao", diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index 0b637869e..62b7025b6 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -183,9 +183,6 @@ "spawn": "Tạo subagent cho công việc nền", "skill_search": "Tìm kiếm skill khả dụng theo từ khóa hoặc mô tả để tìm khả năng phù hợp", "use_skill": "Kích hoạt skill để sử dụng khả năng chuyên biệt (đánh dấu tracing)", - "delegate_search": "Tìm kiếm mục tiêu ủy quyền (ngừng sử dụng)", - "evaluate_loop": "Chạy vòng lặp tạo→đánh giá→chỉnh sửa giữa hai agent (ngừng sử dụng)", - "handoff": "Chuyển cuộc hội thoại sang agent khác (ngừng sử dụng)", "team_tasks": "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm", "team_message": "Gửi tin nhắn trực tiếp hoặc broadcast đến đồng đội trong nhóm agent" } diff --git a/ui/web/src/i18n/locales/vi/traces.json b/ui/web/src/i18n/locales/vi/traces.json index b843fc568..3ed43aea9 100644 --- a/ui/web/src/i18n/locales/vi/traces.json +++ b/ui/web/src/i18n/locales/vi/traces.json @@ -29,8 +29,10 @@ "spans": "Khoảng:", "started": "Bắt đầu:", "delegatedFrom": "Ủy quyền từ:", + "createdAt": "Tạo lúc:", "input": "Đầu vào", "output": "Đầu ra", + "copy": "Sao chép", "spansCount": "Khoảng ({{count}})", "llmCalls": "LLM", "toolCalls": "công cụ", @@ -39,8 +41,12 @@ "span": { "model": "Model:", "tokens": "Token:", + "createdAt": "Tạo lúc:", "input": "Đầu vào:", "output": "Đầu ra:", + "copy": "Sao chép", + "startTime": "Bắt đầu:", + "endTime": "Kết thúc:", "cacheRead": "đọc", "cacheWrite": "ghi", "thinking": "suy nghĩ", diff --git a/ui/web/src/i18n/locales/zh/delegations.json b/ui/web/src/i18n/locales/zh/delegations.json deleted file mode 100644 index 05644761a..000000000 --- a/ui/web/src/i18n/locales/zh/delegations.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "委托", - "description": "Agent委托历史和结果", - "sourceFilterPlaceholder": "源Agent...", - "targetFilterPlaceholder": "目标Agent...", - "filter": "筛选", - "emptyTitle": "暂无委托", - "emptyDescription": "暂无委托记录。当Agent将任务委托给其他Agent时将自动记录。", - "columns": { - "sourceTarget": "源 / 目标", - "task": "任务", - "status": "状态", - "mode": "模式", - "duration": "耗时", - "time": "时间" - }, - "statusFilter": { - "all": "全部", - "pending": "等待中", - "running": "运行中", - "completed": "已完成", - "failed": "已失败", - "cancelled": "已取消" - }, - "detail": { - "title": "委托详情", - "notFound": "未找到委托。", - "source": "源:", - "target": "目标:", - "status": "状态:", - "mode": "模式:", - "duration": "耗时:", - "iterations": "迭代次数:", - "created": "创建时间:", - "completed": "完成时间:", - "trace": "追踪:", - "task": "任务", - "result": "结果", - "error": "错误" - } -} diff --git a/ui/web/src/i18n/locales/zh/events.json b/ui/web/src/i18n/locales/zh/events.json index 580976eba..6ec85b16b 100644 --- a/ui/web/src/i18n/locales/zh/events.json +++ b/ui/web/src/i18n/locales/zh/events.json @@ -11,7 +11,6 @@ "eventCountPlural": "{{count}} 个事件", "categories": { "all": "全部", - "delegation": "委托", "task": "任务", "message": "消息", "agent": "Agent", diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index f10a72d1f..885b7c7ab 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -19,7 +19,6 @@ "config": "配置", "cron": "定时任务", "customTools": "自定义工具", - "delegations": "委托", "logs": "日志", "mcpServers": "MCP 服务器", "memory": "记忆", diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 9bb0473fc..06e8266cd 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -175,8 +175,19 @@ "deniedChannels": "拒绝的Channel", "deniedChannelsHint": "来自这些Channel的消息始终被阻止。", "notifications": "通知", - "progressNotifications": "进度通知", - "progressNotificationsHint": "在异步委托期间,定期向对话发送「您的Team正在处理中...」消息。", + "notifyDispatched": "任务分派", + "notifyDispatchedHint": "任务分配给成员时通知。", + "notifyProgress": "任务进度", + "notifyProgressHint": "成员更新进度时通知。", + "notifyFailed": "任务失败", + "notifyFailedHint": "任务失败时通知。", + "notifyMode": "发送方式", + "notifyModeHint": "通知如何发送到聊天频道。", + "notifyModeDirect": "直接发送", + "notifyModeDirectDesc": "直接将通知发送到聊天频道。快速,无需AI处理。", + "notifyModeLeader": "通过Leader", + "notifyModeLeaderDesc": "Leader代理在发送前自然地重新表述更新内容。", + "notifyModeLeaderWarning": "Leader模式使用AI重新表述消息(消耗token,可能更慢)。", "searchUsers": "搜索用户...", "selectChannel": "选择Channel...", "escalationPolicy": "升级策略", @@ -198,12 +209,12 @@ "followupMaxReminders": "最大提醒次数", "followupMaxRemindersHint": "每个任务的最大提醒次数。0 = 无限制(持续提醒直到用户回复)。", "workspace": "工作区", - "workspaceScope": "工作区共享", - "workspaceScopeHint": "控制代理如何访问团队工作区中的文件。", + "workspaceScope": "工作区范围", + "workspaceScopeHint": "控制团队工作区文件的组织方式。", "workspaceScopeIsolated": "隔离", - "workspaceScopeIsolatedDesc": "每个代理拥有独立的工作区。无法读取或写入其他代理的文件。", + "workspaceScopeIsolatedDesc": "每个对话有独立的工作区文件夹。一个聊天中的文件在另一个聊天中不可见。", "workspaceScopeShared": "共享", - "workspaceScopeSharedDesc": "所有代理共享同一个工作区。可以读取和写入彼此的文件。", + "workspaceScopeSharedDesc": "所有对话共享一个工作区文件夹。在任何聊天中创建的文件对所有人可见。", "teamVersion": "团队版本", "versionBasic": "基础版", "versionAdvanced": "高级版", diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 7aa7098f6..3d2e5b815 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -119,9 +119,6 @@ "spawn": "生成子Agent进行后台工作", "skill_search": "按关键字或描述搜索可用Skill以找到相关能力", "use_skill": "激活Skill以使用其专业能力(追踪标记)", - "delegate_search": "按关键字搜索可用委托目标(已弃用)", - "evaluate_loop": "在两个Agent之间运行生成→评估→修订循环(已弃用)", - "handoff": "将对话转移给另一个Agent(已弃用)", "team_tasks": "查看、创建、更新和完成团队任务板上的任务", "team_message": "向Agent团队中的队友发送直接消息或广播" } diff --git a/ui/web/src/i18n/locales/zh/traces.json b/ui/web/src/i18n/locales/zh/traces.json index a9635f268..e13751b55 100644 --- a/ui/web/src/i18n/locales/zh/traces.json +++ b/ui/web/src/i18n/locales/zh/traces.json @@ -11,7 +11,9 @@ "description": "LLM 调用追踪和性能数据", "detail": { "channel": "Channel:", + "copy": "复制", "copyTraceId": "复制追踪 ID", + "createdAt": "创建时间:", "delegatedFrom": "委托来自:", "duration": "耗时:", "input": "输入", @@ -38,6 +40,9 @@ "cacheRead": "读取", "cacheWrite": "写入", "cached": "已缓存", + "copy": "复制", + "startTime": "开始:", + "endTime": "结束:", "input": "输入:", "model": "模型:", "output": "输出:", diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 936bd156c..f8b10e4f8 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -15,7 +15,6 @@ export const ROUTES = { TRACES: "/traces", TRACE_DETAIL: "/traces/:id", EVENTS: "/events", - DELEGATIONS: "/delegations", USAGE: "/usage", CHANNELS: "/channels", CHANNEL_DETAIL: "/channels/:id", diff --git a/ui/web/src/lib/format.ts b/ui/web/src/lib/format.ts index 22f298134..b046e0dad 100644 --- a/ui/web/src/lib/format.ts +++ b/ui/web/src/lib/format.ts @@ -1,11 +1,14 @@ -export function formatDate(date: string | Date): string { +export function formatDate(date: string | Date, tz?: string): string { const d = typeof date === "string" ? new Date(date) : date; - return d.toLocaleDateString("en-US", { + const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - }); + second: "2-digit", + }; + if (tz) opts.timeZone = resolveTimezone(tz); + return d.toLocaleDateString("en-US", opts); } export function formatRelativeTime(date: string | Date): string { diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 4fe5500c9..5d597fcd9 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -62,10 +62,6 @@ export const queryKeys = { all: ["usage"] as const, records: (params: Record) => ["usage", "records", params] as const, }, - delegations: { - all: ["delegations"] as const, - list: (params: Record) => ["delegations", params] as const, - }, teams: { all: ["teams"] as const, detail: (id: string) => ["teams", id] as const, diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index f47296204..253c1f306 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -122,7 +122,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD {t("create.title")} -
+
diff --git a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx index 655ad09bb..95ff237fb 100644 --- a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx +++ b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx @@ -260,7 +260,7 @@ export function ChannelInstanceFormDialog({ {/* === FORM STEP === */} {step === "form" && ( <> -
+
setName(slugify(e.target.value))} placeholder={t("form.keyPlaceholder")} disabled={!!instance} /> @@ -380,7 +380,7 @@ export function ChannelInstanceFormDialog({ {/* === CONFIG STEP (rendered by registered component) === */} {step === "config" && createdInstanceId && ConfigStep && ( <> -
+
{t("create.title")} -
+
setName(slugify(e.target.value))} placeholder={t("create.namePlaceholder")} /> diff --git a/ui/web/src/pages/cron/cron-run-log-dialog.tsx b/ui/web/src/pages/cron/cron-run-log-dialog.tsx index 1a54d21ed..2181e9346 100644 --- a/ui/web/src/pages/cron/cron-run-log-dialog.tsx +++ b/ui/web/src/pages/cron/cron-run-log-dialog.tsx @@ -28,43 +28,45 @@ export function CronRunLogDialog({ return ( - + {t("runLog.title", { name: jobName })} - {loading && entries.length === 0 ? ( -
-
-
- ) : entries.length === 0 ? ( -

- {t("runLog.noHistory")} -

- ) : ( -
- {entries.map((entry: CronRunLogEntry, i: number) => ( -
-
- - {formatDate(new Date(entry.ts))} - - - {entry.status || "unknown"} - +
+ {loading && entries.length === 0 ? ( +
+
+
+ ) : entries.length === 0 ? ( +

+ {t("runLog.noHistory")} +

+ ) : ( +
+ {entries.map((entry: CronRunLogEntry, i: number) => ( +
+
+ + {formatDate(new Date(entry.ts))} + + + {entry.status || "unknown"} + +
+ {entry.summary && ( +

{entry.summary}

+ )} + {entry.error && ( +

{entry.error}

+ )}
- {entry.summary && ( -

{entry.summary}

- )} - {entry.error && ( -

{entry.error}

- )} -
- ))} -
- )} + ))} +
+ )} +
); diff --git a/ui/web/src/pages/custom-tools/custom-tool-form-dialog.tsx b/ui/web/src/pages/custom-tools/custom-tool-form-dialog.tsx index de5cd8f5c..64ac92c1b 100644 --- a/ui/web/src/pages/custom-tools/custom-tool-form-dialog.tsx +++ b/ui/web/src/pages/custom-tools/custom-tool-form-dialog.tsx @@ -97,7 +97,7 @@ export function CustomToolFormDialog({ open, onOpenChange, tool, onSubmit }: Cus {tool ? t("custom.form.editTitle") : t("custom.form.createTitle")} -
+
setName(slugify(e.target.value))} placeholder={t("custom.form.namePlaceholder")} /> diff --git a/ui/web/src/pages/delegations/delegation-detail-dialog.tsx b/ui/web/src/pages/delegations/delegation-detail-dialog.tsx deleted file mode 100644 index d688ab42e..000000000 --- a/ui/web/src/pages/delegations/delegation-detail-dialog.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { formatDate, formatDuration } from "@/lib/format"; -import { TraceDetailDialog } from "@/pages/traces/trace-detail-dialog"; -import type { DelegationHistoryRecord } from "@/types/delegation"; -import type { TraceData, SpanData } from "@/types/trace"; - -interface DelegationDetailDialogProps { - delegationId: string; - onClose: () => void; - getDelegation: (id: string) => Promise; - getTrace: (traceId: string) => Promise<{ trace: TraceData; spans: SpanData[] } | null>; -} - -export function DelegationDetailDialog({ delegationId, onClose, getDelegation, getTrace }: DelegationDetailDialogProps) { - const { t } = useTranslation("delegations"); - const [record, setRecord] = useState(null); - const [loading, setLoading] = useState(true); - const [viewingTraceId, setViewingTraceId] = useState(null); - - useEffect(() => { - setLoading(true); - getDelegation(delegationId) - .then((r) => setRecord(r)) - .finally(() => setLoading(false)); - }, [delegationId, getDelegation]); - - const statusVariant = - record?.status === "completed" - ? "success" - : record?.status === "failed" - ? "destructive" - : record?.status === "running" || record?.status === "pending" - ? "info" - : "secondary"; - - return ( - onClose()}> - - - {t("detail.title")} - - - {loading && !record ? ( -
-
-
- ) : !record ? ( -

{t("detail.notFound")}

- ) : ( -
- {/* Summary grid */} -
-
- {t("detail.source")}{" "} - {record.source_agent_key || record.source_agent_id.slice(0, 8)} -
-
- {t("detail.target")}{" "} - {record.target_agent_key || record.target_agent_id.slice(0, 8)} -
-
- {t("detail.status")}{" "} - {record.status} -
-
- {t("detail.mode")}{" "} - {record.mode} -
-
- {t("detail.duration")}{" "} - {formatDuration(record.duration_ms)} -
-
- {t("detail.iterations")}{" "} - {record.iterations} -
-
- {t("detail.created")}{" "} - {formatDate(record.created_at)} -
-
- {t("detail.completed")}{" "} - {record.completed_at ? formatDate(record.completed_at) : "—"} -
-
- - {record.trace_id && ( -
- {t("detail.trace")}{" "} - -
- )} - - {/* Task */} -
-

{t("detail.task")}

-
{record.task}
-
- - {/* Result */} - {record.result && ( -
-

{t("detail.result")}

-
-                  {record.result}
-                
-
- )} - - {/* Error */} - {record.error && ( -
-

{t("detail.error")}

-

{record.error}

-
- )} -
- )} - - - {viewingTraceId && ( - setViewingTraceId(null)} - getTrace={getTrace} - onNavigateTrace={setViewingTraceId} - /> - )} -
- ); -} diff --git a/ui/web/src/pages/delegations/delegations-page.tsx b/ui/web/src/pages/delegations/delegations-page.tsx deleted file mode 100644 index 6adf320b7..000000000 --- a/ui/web/src/pages/delegations/delegations-page.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { ArrowRightLeft, RefreshCw, Search } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { PageHeader } from "@/components/shared/page-header"; -import { EmptyState } from "@/components/shared/empty-state"; -import { Pagination } from "@/components/shared/pagination"; -import { TableSkeleton } from "@/components/shared/loading-skeleton"; -import { useWsEvent } from "@/hooks/use-ws-event"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; -import { Events } from "@/api/protocol"; -import { formatDate, formatDuration } from "@/lib/format"; -import { useDelegations } from "./hooks/use-delegations"; -import { useTraces } from "@/pages/traces/hooks/use-traces"; -import { DelegationDetailDialog } from "./delegation-detail-dialog"; -import { useMinLoading } from "@/hooks/use-min-loading"; -import { useDeferredLoading } from "@/hooks/use-deferred-loading"; -import type { AgentEventPayload } from "@/types/chat"; -import type { DelegationHistoryRecord } from "@/types/delegation"; - -export function DelegationsPage() { - const { t } = useTranslation("delegations"); - const { t: tc } = useTranslation("common"); - const { delegations, total, loading, load, getDelegation } = useDelegations(); - const { getTrace } = useTraces(); - const spinning = useMinLoading(loading); - const showSkeleton = useDeferredLoading(loading && delegations.length === 0); - const [sourceFilter, setSourceFilter] = useState(""); - const [targetFilter, setTargetFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [selectedId, setSelectedId] = useState(null); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - - const sourceRef = useRef(sourceFilter); - sourceRef.current = sourceFilter; - const targetRef = useRef(targetFilter); - targetRef.current = targetFilter; - const statusRef = useRef(statusFilter); - statusRef.current = statusFilter; - const pageRef = useRef(page); - pageRef.current = page; - const pageSizeRef = useRef(pageSize); - pageSizeRef.current = pageSize; - - const buildFilters = useCallback(() => ({ - source_agent_id: sourceRef.current || undefined, - target_agent_id: targetRef.current || undefined, - status: statusRef.current !== "all" ? statusRef.current : undefined, - limit: pageSizeRef.current, - offset: (pageRef.current - 1) * pageSizeRef.current, - }), []); - - useEffect(() => { - load({ limit: pageSize, offset: (page - 1) * pageSize }); - }, [load, page, pageSize]); - - const handleRefresh = () => load(buildFilters()); - - const debouncedRefresh = useDebouncedCallback(() => load(buildFilters()), 3000); - - const handleAgentEvent = useCallback( - (payload: unknown) => { - const event = payload as AgentEventPayload; - if (!event) return; - if (event.type === "run.completed" || event.type === "run.failed") { - debouncedRefresh(); - } - }, - [debouncedRefresh], - ); - - useWsEvent(Events.AGENT, handleAgentEvent); - - const handleFilterSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setPage(1); - handleRefresh(); - }; - - return ( -
- - {tc("refresh")} - - } - /> - -
-
- - setSourceFilter(e.target.value)} - placeholder={t("sourceFilterPlaceholder")} - className="pl-9" - /> -
-
- - setTargetFilter(e.target.value)} - placeholder={t("targetFilterPlaceholder")} - className="pl-9" - /> -
- - -
- -
- {showSkeleton ? ( - - ) : delegations.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {delegations.map((d: DelegationHistoryRecord) => ( - setSelectedId(d.id)} - > - - - - - - - - ))} - -
{t("columns.sourceTarget")}{t("columns.task")}{t("columns.status")}{t("columns.mode")}{t("columns.duration")}{t("columns.time")}
- {d.source_agent_key || d.source_agent_id.slice(0, 8)} - - {d.target_agent_key || d.target_agent_id.slice(0, 8)} - - {d.task} - - - - {d.mode} - - {formatDuration(d.duration_ms)} - - {formatDate(d.created_at)} -
- { setPageSize(size); setPage(1); }} - /> -
- )} -
- - {selectedId && ( - setSelectedId(null)} - getDelegation={getDelegation} - getTrace={getTrace} - /> - )} -
- ); -} - -function StatusBadge({ status }: { status: string }) { - const variant = - status === "completed" - ? "success" - : status === "failed" - ? "destructive" - : status === "running" || status === "pending" - ? "info" - : "secondary"; - - return {status || "unknown"}; -} diff --git a/ui/web/src/pages/delegations/hooks/use-delegations.ts b/ui/web/src/pages/delegations/hooks/use-delegations.ts deleted file mode 100644 index b8823b1eb..000000000 --- a/ui/web/src/pages/delegations/hooks/use-delegations.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, useCallback } from "react"; -import { useHttp } from "@/hooks/use-ws"; -import type { DelegationHistoryRecord, DelegationListFilters } from "@/types/delegation"; - -export function useDelegations() { - const http = useHttp(); - const [delegations, setDelegations] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - - const load = useCallback( - async (filters: DelegationListFilters = {}) => { - setLoading(true); - try { - const params: Record = {}; - if (filters.source_agent_id) params.source_agent_id = filters.source_agent_id; - if (filters.target_agent_id) params.target_agent_id = filters.target_agent_id; - if (filters.team_id) params.team_id = filters.team_id; - if (filters.user_id) params.user_id = filters.user_id; - if (filters.status) params.status = filters.status; - if (filters.limit) params.limit = String(filters.limit); - if (filters.offset !== undefined) params.offset = String(filters.offset); - - const res = await http.get<{ records: DelegationHistoryRecord[]; total?: number }>("/v1/delegations", params); - setDelegations(res.records ?? []); - setTotal(res.total ?? 0); - } catch { - // ignore - } finally { - setLoading(false); - } - }, - [http], - ); - - const getDelegation = useCallback( - async (id: string): Promise => { - try { - return await http.get(`/v1/delegations/${id}`); - } catch { - return null; - } - }, - [http], - ); - - return { delegations, total, loading, load, getDelegation }; -} diff --git a/ui/web/src/pages/events/event-sections/delegation-event-cards.tsx b/ui/web/src/pages/events/event-sections/delegation-event-cards.tsx deleted file mode 100644 index 90ba7afec..000000000 --- a/ui/web/src/pages/events/event-sections/delegation-event-cards.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { formatDuration } from "@/lib/format"; -import type { TeamEventEntry } from "@/stores/use-team-event-store"; -import type { - DelegationEventPayload, - DelegationProgressPayload, - DelegationAccumulatedPayload, - DelegationAnnouncePayload, - QualityGateRetryPayload, -} from "@/types/team-events"; - -interface Props { - entry: TeamEventEntry; - resolveAgent: (keyOrId: string | undefined) => string; -} - -export function DelegationEventCard({ entry, resolveAgent }: Props) { - switch (entry.event) { - case "delegation.started": - case "delegation.completed": - case "delegation.failed": - case "delegation.cancelled": - return ; - case "delegation.progress": - return ; - case "delegation.accumulated": - return ; - case "delegation.announce": - return ; - case "delegation.quality_gate.retry": - return ; - default: - return
{JSON.stringify(entry.payload, null, 2)}
; - } -} - -function DelegationLifecycleCard({ entry, resolveAgent }: Props) { - const p = entry.payload as DelegationEventPayload; - const source = p.source_display_name || resolveAgent(p.source_agent_key); - const target = p.target_display_name || resolveAgent(p.target_agent_key); - return ( -
-
- {source} - - {target} - {p.mode && ( - - {p.mode} - - )} -
- {p.task &&

{p.task}

} -
- {p.delegation_id && ( - - deleg: {p.delegation_id.slice(0, 8)} - - )} - {p.elapsed_ms != null && p.elapsed_ms > 0 && ( - {formatDuration(p.elapsed_ms)} - )} - {p.error && {p.error}} -
-
- ); -} - -type ResolverProp = { resolveAgent: (keyOrId: string | undefined) => string }; - -function DelegationProgressCard({ payload: p, resolveAgent }: { payload: DelegationProgressPayload } & ResolverProp) { - return ( -
-

- {resolveAgent(p.source_agent_key)} has {p.active_delegations.length} active delegation(s) -

-
- {p.active_delegations.map((d) => ( -
- {d.target_display_name || resolveAgent(d.target_agent_key)} - {formatDuration(d.elapsed_ms)} -
- ))} -
-
- ); -} - -function DelegationAccumulatedCard({ payload: p, resolveAgent }: { payload: DelegationAccumulatedPayload } & ResolverProp) { - return ( -
- {p.target_display_name || resolveAgent(p.target_agent_key)} - result accumulated, - {p.siblings_remaining} sibling(s) remaining - {p.elapsed_ms != null && p.elapsed_ms > 0 && ( - - ({formatDuration(p.elapsed_ms)}) - - )} -
- ); -} - -function DelegationAnnounceCard({ payload: p, resolveAgent }: { payload: DelegationAnnouncePayload } & ResolverProp) { - return ( -
-
- {p.source_display_name || resolveAgent(p.source_agent_key)} - announcing {p.results.length} result(s) - ({formatDuration(p.total_elapsed_ms)}) - {p.has_media && ( - media - )} - {p.completed_task_ids && p.completed_task_ids.length > 0 && ( - - {p.completed_task_ids.length} task(s) auto-completed - - )} -
-
- {p.results.map((r) => ( -
-
- {r.display_name || resolveAgent(r.agent_key)} - {r.has_media && ( - media - )} -
- {r.content_preview && ( -

{r.content_preview}

- )} -
- ))} -
-
- ); -} - -function QualityGateRetryCard({ payload: p, resolveAgent }: { payload: QualityGateRetryPayload } & ResolverProp) { - return ( -
-
- {resolveAgent(p.target_agent_key)} - - retry {p.attempt}/{p.max_retries} - - {p.gate_type} -
- {p.feedback && ( -

{p.feedback}

- )} -
- ); -} diff --git a/ui/web/src/pages/events/event-sections/event-card.tsx b/ui/web/src/pages/events/event-sections/event-card.tsx index 3d9edd45d..2c8c9d8c2 100644 --- a/ui/web/src/pages/events/event-sections/event-card.tsx +++ b/ui/web/src/pages/events/event-sections/event-card.tsx @@ -6,7 +6,6 @@ import { formatRelativeTime } from "@/lib/format"; import type { TeamEventEntry } from "@/stores/use-team-event-store"; import { useAgentResolver } from "./use-agent-resolver"; import { getCategoryConfig } from "./event-categories"; -import { DelegationEventCard } from "./delegation-event-cards"; import { TaskEventCard } from "./task-event-cards"; import { MessageEventCard } from "./message-event-card"; import { AgentEventCard } from "./agent-event-cards"; @@ -26,9 +25,7 @@ export function EventCard({ entry, resolveTeam }: EventCardProps) { const CategoryIcon = config.icon; let content: React.ReactNode; - if (event.startsWith("delegation.")) { - content = ; - } else if (event.startsWith("team.task.")) { + if (event.startsWith("team.task.")) { content = ; } else if (event === "team.message.sent") { content = ; diff --git a/ui/web/src/pages/events/event-sections/event-categories.ts b/ui/web/src/pages/events/event-sections/event-categories.ts index dcaa59af3..2fbd96303 100644 --- a/ui/web/src/pages/events/event-sections/event-categories.ts +++ b/ui/web/src/pages/events/event-sections/event-categories.ts @@ -1,5 +1,4 @@ import { - ArrowRightLeft, ListTodo, MessageCircle, Bot, @@ -15,13 +14,6 @@ export interface EventCategoryConfig { iconColor: string; } -const delegation: EventCategoryConfig = { - label: "Delegation", - icon: ArrowRightLeft, - borderColor: "border-l-blue-500", - iconColor: "text-blue-500", -}; - const teamTask: EventCategoryConfig = { label: "Task", icon: ListTodo, @@ -58,7 +50,6 @@ const agentLink: EventCategoryConfig = { }; export function getCategoryConfig(event: string): EventCategoryConfig { - if (event.startsWith("delegation.")) return delegation; if (event.startsWith("team.task.")) return teamTask; if (event === "team.message.sent") return teamMessage; if (event === "agent") return agent; diff --git a/ui/web/src/pages/events/event-sections/event-detail-dialog.tsx b/ui/web/src/pages/events/event-sections/event-detail-dialog.tsx index 09972be81..8399bfe06 100644 --- a/ui/web/src/pages/events/event-sections/event-detail-dialog.tsx +++ b/ui/web/src/pages/events/event-sections/event-detail-dialog.tsx @@ -24,7 +24,7 @@ export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) { return ( !open && onClose()}> - + @@ -36,7 +36,7 @@ export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) { -
+
-
+
{tab === "content" && (
{detail && ( diff --git a/ui/web/src/pages/memory/memory-search-dialog.tsx b/ui/web/src/pages/memory/memory-search-dialog.tsx index fd97d4c9b..c5bf27c6a 100644 --- a/ui/web/src/pages/memory/memory-search-dialog.tsx +++ b/ui/web/src/pages/memory/memory-search-dialog.tsx @@ -65,7 +65,7 @@ export function MemorySearchDialog({ open, onOpenChange, agentId }: MemorySearch
-
+
{results.length === 0 ? (
{searching ? t("searchDialog.searching") : t("searchDialog.noResults")} diff --git a/ui/web/src/pages/pending-messages/message-list-dialog.tsx b/ui/web/src/pages/pending-messages/message-list-dialog.tsx index 597147b1f..4cc343d24 100644 --- a/ui/web/src/pages/pending-messages/message-list-dialog.tsx +++ b/ui/web/src/pages/pending-messages/message-list-dialog.tsx @@ -32,7 +32,7 @@ export function MessageListDialog({ return ( onClose()}> - + {t("dialog.title", { name: group.group_title || group.history_key })} @@ -40,37 +40,39 @@ export function MessageListDialog({ - {loading ? ( -
-
-
- ) : messages.length === 0 ? ( -

{t("dialog.noMessages")}

- ) : ( -
- {messages.map((msg) => ( -
-
- {msg.sender} - ({msg.sender_id}) - {msg.is_summary && ( - {t("dialog.summary")} - )} - - {formatDate(msg.created_at)} - +
+ {loading ? ( +
+
+
+ ) : messages.length === 0 ? ( +

{t("dialog.noMessages")}

+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {msg.sender} + ({msg.sender_id}) + {msg.is_summary && ( + {t("dialog.summary")} + )} + + {formatDate(msg.created_at)} + +
+

{msg.body}

-

{msg.body}

-
- ))} -
- )} + ))} +
+ )} +
); diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 79f7a7a36..68cdd64e8 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -145,7 +145,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi {isEdit ? t("form.editTitle") : t("form.createTitle")} {t("form.configure")} -
+
{/* Provider type selector — always shown in create mode */} {!isEdit && ( {t("detail.files")}} - + {skill.content ? (
diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index 6330ea0db..bb4cd6805 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -108,6 +108,10 @@ export const BoardContainer = memo(function BoardContainer({ const handleCreateTask = useCallback(() => toast.info(t("board.createViaChat")), [t]); const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []); const handleCloseDetail = useCallback(() => setSelectedTask(null), []); + const handleNavigateTask = useCallback((taskId: string) => { + const found = tasks.find((t) => t.id === taskId); + if (found) setSelectedTask(found); + }, [tasks]); // Delete handler for kanban cards (confirm + call API) const deleteTaskRef = useRef(deleteTask); @@ -146,6 +150,7 @@ export const BoardContainer = memo(function BoardContainer({ isTeamV2={isTeamV2} groupBy={groupBy} emojiLookup={emojiLookup} + taskLookup={taskLookup} onTaskClick={handleTaskClick} onDeleteTask={deleteTask ? handleDeleteTask : undefined} /> @@ -174,6 +179,7 @@ export const BoardContainer = memo(function BoardContainer({ taskLookup={taskLookup} memberLookup={memberLookup} emojiLookup={emojiLookup} + onNavigateTask={handleNavigateTask} /> )}
diff --git a/ui/web/src/pages/teams/board/board-header.tsx b/ui/web/src/pages/teams/board/board-header.tsx index e0dfde2c0..922bc2b40 100644 --- a/ui/web/src/pages/teams/board/board-header.tsx +++ b/ui/web/src/pages/teams/board/board-header.tsx @@ -58,19 +58,22 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
{/* Actions */} - -
); diff --git a/ui/web/src/pages/teams/board/board-utils.ts b/ui/web/src/pages/teams/board/board-utils.ts index c5e2d7337..cdac4303d 100644 --- a/ui/web/src/pages/teams/board/board-utils.ts +++ b/ui/web/src/pages/teams/board/board-utils.ts @@ -3,9 +3,9 @@ import type { TeamTaskData, TeamMemberData } from "@/types/team"; /** All kanban column statuses in display order */ export const KANBAN_STATUSES = [ "pending", + "blocked", "in_progress", "completed", - "blocked", "failed", "cancelled", ] as const; diff --git a/ui/web/src/pages/teams/board/kanban-board.tsx b/ui/web/src/pages/teams/board/kanban-board.tsx index 55f3a6993..99576d022 100644 --- a/ui/web/src/pages/teams/board/kanban-board.tsx +++ b/ui/web/src/pages/teams/board/kanban-board.tsx @@ -9,11 +9,12 @@ interface KanbanBoardProps { isTeamV2?: boolean; groupBy: GroupBy; emojiLookup?: Map; + taskLookup?: Map; onTaskClick: (task: TeamTaskData) => void; onDeleteTask?: (taskId: string) => void; } -export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) { +export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) { const grouped = useMemo(() => groupTasksBy(tasks, groupBy), [tasks, groupBy]); const columns = useMemo(() => { @@ -39,6 +40,7 @@ export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, tasks={grouped.get(col) ?? []} isTeamV2={isTeamV2} emojiLookup={emojiLookup} + taskLookup={taskLookup} onTaskClick={onTaskClick} onDeleteTask={onDeleteTask} /> diff --git a/ui/web/src/pages/teams/board/kanban-card.tsx b/ui/web/src/pages/teams/board/kanban-card.tsx index cd01ecac0..31b48edad 100644 --- a/ui/web/src/pages/teams/board/kanban-card.tsx +++ b/ui/web/src/pages/teams/board/kanban-card.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; -import { Trash2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { Trash2, Ban } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { isTaskLocked } from "./board-utils"; @@ -24,20 +25,26 @@ interface KanbanCardProps { task: TeamTaskData; isTeamV2?: boolean; emojiLookup?: Map; + taskLookup?: Map; onClick: () => void; onDelete?: (taskId: string) => void; } -export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, onClick, onDelete }: KanbanCardProps) { +export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, taskLookup, onClick, onDelete }: KanbanCardProps) { const { t } = useTranslation("teams"); const locked = isTaskLocked(task); const blocked = task.status === "blocked"; const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id); const canDelete = onDelete && isTerminalStatus(task.status); const prio = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0]!; + const hasBlockers = task.blocked_by && task.blocked_by.length > 0; return ( -
-

{task.subject}

+

{task.subject}

+ + {/* Blocked-by row */} + {hasBlockers && ( +

+ + + {task.blocked_by!.map((id) => taskLookup?.get(id) || id.slice(0, 8)).join(", ")} + +

+ )} {/* Bottom row: owner + type badge + priority */}
@@ -95,6 +112,6 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup {task.progress_percent}%
)} -
+ ); }); diff --git a/ui/web/src/pages/teams/board/kanban-column.tsx b/ui/web/src/pages/teams/board/kanban-column.tsx index c9b63df69..eb96020f9 100644 --- a/ui/web/src/pages/teams/board/kanban-column.tsx +++ b/ui/web/src/pages/teams/board/kanban-column.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { AnimatePresence, LayoutGroup } from "framer-motion"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { STATUS_COLORS } from "./board-utils"; @@ -11,11 +12,12 @@ interface KanbanColumnProps { tasks: TeamTaskData[]; isTeamV2?: boolean; emojiLookup?: Map; + taskLookup?: Map; onTaskClick: (task: TeamTaskData) => void; onDeleteTask?: (taskId: string) => void; } -export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) { +export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) { const { t } = useTranslation("teams"); return ( @@ -30,16 +32,21 @@ export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, {tasks.length === 0 ? (
{t("board.emptyColumn")}
) : ( - tasks.map((task) => ( - onTaskClick(task)} - onDelete={onDeleteTask} - /> - )) + + + {tasks.map((task) => ( + onTaskClick(task)} + onDelete={onDeleteTask} + /> + ))} + + )}
diff --git a/ui/web/src/pages/teams/board/team-info-dialog.tsx b/ui/web/src/pages/teams/board/team-info-dialog.tsx index dcd446989..88a6859bd 100644 --- a/ui/web/src/pages/teams/board/team-info-dialog.tsx +++ b/ui/web/src/pages/teams/board/team-info-dialog.tsx @@ -34,7 +34,7 @@ export function TeamInfoDialog({ return ( <> - + {team.name} @@ -52,6 +52,7 @@ export function TeamInfoDialog({ +
{/* Team overview */}
{team.description && ( @@ -72,6 +73,7 @@ export function TeamInfoDialog({ {/* Settings form */} { onSaved(); onOpenChange(false); }} /> +
diff --git a/ui/web/src/pages/teams/board/team-workspace-dialog.tsx b/ui/web/src/pages/teams/board/team-workspace-dialog.tsx index 9bdadbc49..c06ba0832 100644 --- a/ui/web/src/pages/teams/board/team-workspace-dialog.tsx +++ b/ui/web/src/pages/teams/board/team-workspace-dialog.tsx @@ -37,16 +37,45 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team const [activePath, setActivePath] = useState(null); const scopeValue = selectedScope === "__all__" ? "" : selectedScope; + // Stable scope list: populated once from "all" listing, not recalculated on filter. + const [cachedScopes, setCachedScopes] = useState([]); + // Load files for the selected scope. On "all" load, also cache scope list. const load = useCallback(() => { - listFiles(teamId, scopeValue || undefined); + listFiles(teamId, scopeValue || undefined).then((result) => { + // Only update cached scopes from the "all" listing (no filter applied). + if (!scopeValue && result.length > 0) { + const seen = new Set(); + const derived: ScopeEntry[] = []; + for (const f of result) { + if (f.chat_id && !seen.has(f.chat_id)) { + seen.add(f.chat_id); + derived.push({ channel: "", chat_id: f.chat_id }); + } + } + derived.sort((a, b) => a.chat_id.localeCompare(b.chat_id)); + setCachedScopes(derived); + } + }); setFileContent(null); setActivePath(null); }, [teamId, listFiles, scopeValue]); useEffect(() => { - if (open) load(); - }, [open, load]); + if (open) { + setSelectedScope("__all__"); // reset filter on open + load(); + } + }, [open]); // intentionally only depend on `open`, not `load` + + // Re-fetch when scope changes (but not on initial open — handled above). + useEffect(() => { + if (open && scopeValue) { + listFiles(teamId, scopeValue); + setFileContent(null); + setActivePath(null); + } + }, [open, scopeValue, teamId, listFiles]); // Map relative name → absolute disk path (for HTTP file serving). const nameToAbsPath = useMemo(() => { @@ -55,6 +84,9 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team return m; }, [files]); + // Use provided scopes, or cached scopes from first "all" load. + const effectiveScopes = scopes.length > 0 ? scopes : cachedScopes; + const tree = useMemo( () => buildTree(files.map((f) => ({ path: f.name, @@ -122,14 +154,14 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
{t("workspace.title")}
- {scopes.length > 0 && ( + {effectiveScopes.length > 1 && ( (null); - const spinning = useMinLoading(loading); - - if (loading && records.length === 0) return ; - - if (records.length === 0) { - return ( - - ); - } - - return ( - <> -
- -
-
- - - - - - - - - - - - {records.map((d) => ( - setSelectedId(d.id)} - > - - - - - - - ))} - -
{t("delegations.columns.sourceTarget")}{t("delegations.columns.task")}{t("delegations.columns.status")}{t("delegations.columns.duration")}{t("delegations.columns.time")}
- {d.source_agent_key || d.source_agent_id.slice(0, 8)} - - {d.target_agent_key || d.target_agent_id.slice(0, 8)} - - {d.task} - - - - {formatDuration(d.duration_ms)} - - {formatDate(d.created_at)} -
-
- - {selectedId && ( - setSelectedId(null)} - getDelegation={getDelegation} - getTrace={getTrace} - /> - )} - - ); -} - -function StatusBadge({ status }: { status: string }) { - const variant = - status === "completed" - ? "success" - : status === "failed" - ? "destructive" - : status === "running" || status === "pending" - ? "info" - : "secondary"; - - return {status || "unknown"}; -} diff --git a/ui/web/src/pages/teams/team-settings-tab.tsx b/ui/web/src/pages/teams/team-settings-tab.tsx index ce2378f86..77dc6bda8 100644 --- a/ui/web/src/pages/teams/team-settings-tab.tsx +++ b/ui/web/src/pages/teams/team-settings-tab.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Combobox } from "@/components/ui/combobox"; -import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync } from "lucide-react"; +import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CHANNEL_TYPES } from "@/constants/channels"; import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team"; @@ -71,7 +71,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) const [denyUserIds, setDenyUserIds] = useState(initial.deny_user_ids ?? []); const [allowChannels, setAllowChannels] = useState(initial.allow_channels ?? []); const [denyChannels, setDenyChannels] = useState(initial.deny_channels ?? []); - const [progressNotifications, setProgressNotifications] = useState(initial.progress_notifications ?? false); + const initNotify = initial.notifications ?? {}; + const [notifyDispatched, setNotifyDispatched] = useState(initNotify.dispatched ?? true); + const [notifyProgress, setNotifyProgress] = useState(initNotify.progress ?? true); + const [notifyFailed, setNotifyFailed] = useState(initNotify.failed ?? true); + const [notifyMode, setNotifyMode] = useState<"direct" | "leader">(initNotify.mode ?? "direct"); const [escalationMode, setEscalationMode] = useState(initial.escalation_mode ?? ""); const [escalationActions, setEscalationActions] = useState(initial.escalation_actions ?? []); const [followupInterval, setFollowupInterval] = useState(initial.followup_interval_minutes ?? 30); @@ -97,7 +101,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) setDenyUserIds(s.deny_user_ids ?? []); setAllowChannels(s.allow_channels ?? []); setDenyChannels(s.deny_channels ?? []); - setProgressNotifications(s.progress_notifications ?? false); + const sn = s.notifications ?? {}; + setNotifyDispatched(sn.dispatched ?? true); + setNotifyProgress(sn.progress ?? true); + setNotifyFailed(sn.failed ?? true); + setNotifyMode(sn.mode ?? "direct"); setEscalationMode(s.escalation_mode ?? ""); setEscalationActions(s.escalation_actions ?? []); setFollowupInterval(s.followup_interval_minutes ?? 30); @@ -117,14 +125,19 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) if (denyUserIds.length > 0) settings.deny_user_ids = denyUserIds; if (allowChannels.length > 0) settings.allow_channels = allowChannels; if (denyChannels.length > 0) settings.deny_channels = denyChannels; - if (progressNotifications) settings.progress_notifications = true; + settings.notifications = { + dispatched: notifyDispatched, + progress: notifyProgress, + failed: notifyFailed, + mode: notifyMode, + }; if (escalationMode) { settings.escalation_mode = escalationMode; if (escalationActions.length > 0) settings.escalation_actions = escalationActions; } if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval; if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders; - if (workspaceScope === "shared") settings.workspace_scope = "shared"; + settings.workspace_scope = workspaceScope || "isolated"; if (version >= 2) settings.version = version; await updateTeamSettings(teamId, settings); setSaved(true); @@ -135,7 +148,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) } finally { setSaving(false); } - }, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, progressNotifications, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]); + }, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyMode, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]); const userOptions = knownUsers.map((u) => ({ value: u, label: u })); const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label })); @@ -192,87 +205,70 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
- {/* User Access Control */} -
-

{t("settings.userAccessControl")}

-
-
- -

- {t("settings.allowedUsersHint")} -

- -
-
- -

- {t("settings.deniedUsersHint")} -

- -
-
-
- - {/* Channel Restrictions */} -
-

{t("settings.channelRestrictions")}

-
-
- -

- {t("settings.allowedChannelsHint")} -

- -
-
- -

- {t("settings.deniedChannelsHint")} -

- -
-
-
- {/* Notifications */}

{t("settings.notifications")}

-
+
-
+
- {t("settings.progressNotifications")} - +
+ {t("settings.notifyDispatched")} +

{t("settings.notifyDispatchedHint")}

+
+ +
+
+
+ {t("settings.notifyProgress")} +

{t("settings.notifyProgressHint")}

+
+ +
+
+
+ {t("settings.notifyFailed")} +

{t("settings.notifyFailedHint")}

+
+ +
+
+ {t("settings.notifyMode")} +
+ {([ + { value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" }, + { value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" }, + ]).map((opt) => ( + + ))} +
+ {notifyMode === "leader" && ( +

+ ⚠️ {t("settings.notifyModeLeaderWarning")} +

+ )}
-

- {t("settings.progressNotificationsHint")} -

@@ -303,7 +299,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) type="button" onClick={() => setWorkspaceScope(opt.value)} className={ - "flex items-start gap-3 rounded-lg border p-3 text-left transition-colors " + + "flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " + (workspaceScope === opt.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50") @@ -387,6 +383,68 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
} + {/* User Access Control */} +
+

{t("settings.userAccessControl")}

+
+
+ +

+ {t("settings.allowedUsersHint")} +

+ +
+
+ +

+ {t("settings.deniedUsersHint")} +

+ +
+
+
+ + {/* Channel Restrictions */} +
+

{t("settings.channelRestrictions")}

+
+
+ +

+ {t("settings.allowedChannelsHint")} +

+ +
+
+ +

+ {t("settings.deniedChannelsHint")} +

+ +
+
+
+ {/* Save button */}
- ))} -
- {/* Scope filter */} - {scopes && scopes.length > 0 && ( - - )} -
-
- - -
-
- - - - {/* Create Task Dialog */} - - - - {t("tasks.createTaskTitle")} - -
- {/* Subject */} -
- - setSubject(e.target.value)} - placeholder={t("tasks.subjectPlaceholder")} - className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm" - autoFocus - /> -
- - {/* Description */} -
- -