From 6895e369f6e1df4a186dd7e0016149963e4116a9 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Fri, 6 Mar 2026 18:51:11 +0700 Subject: [PATCH] refactor: remove standalone mode, consolidate to managed-only (PostgreSQL) (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove standalone mode code: file-based stores, standalone gateway, heartbeat service, SQLite memory, standalone docker-compose - Rename docker-compose.managed.yml → docker-compose.postgres.yml - Clean up ~130 Go comments referencing "managed mode" qualifier - Simplify docker-compose.yml env vars (providers/channels via web UI) - Update .env.example to essential vars only (token + encryption key) - Add setup wizard UI (provider → agent → channel bootstrap flow) - Add logs.tail WebSocket handler for live log streaming - Add cursor-pointer to interactive UI components - Clean up config page (remove standalone-only sections) - Update README and docs for managed-only architecture --- .env.example | 29 +- CLAUDE.md | 15 +- README.md | 124 ++--- cmd/agent_chat.go | 14 +- cmd/agent_chat_client.go | 4 +- cmd/agent_chat_standalone.go | 233 --------- cmd/cli_helpers.go | 21 +- cmd/cron_cmd.go | 50 +- cmd/doctor.go | 19 +- cmd/gateway.go | 455 +++++++----------- cmd/gateway_agents.go | 244 +--------- cmd/gateway_callbacks.go | 2 - cmd/gateway_consumer.go | 4 +- cmd/gateway_managed.go | 18 +- cmd/gateway_methods.go | 13 +- cmd/gateway_standalone.go | 110 ----- cmd/onboard.go | 134 ++---- cmd/onboard_auto.go | 45 +- cmd/onboard_helpers.go | 4 +- cmd/onboard_managed.go | 1 - cmd/sessions_cmd.go | 47 +- cmd/upgrade.go | 9 +- docker-compose.browser.yml | 4 +- docker-compose.otel.yml | 4 +- ...managed.yml => docker-compose.postgres.yml | 7 +- docker-compose.sandbox.yml | 4 +- docker-compose.selfservice.yml | 2 +- docker-compose.standalone.yml | 19 - docker-compose.tailscale.yml | 4 +- docker-compose.upgrade.yml | 7 +- docker-compose.yml | 40 +- docs/00-architecture-overview.md | 92 +--- docs/01-agent-loop.md | 3 +- docs/03-tools-system.md | 1 - docs/04-gateway-protocol.md | 1 - docs/05-channels-messaging.md | 10 +- docs/06-store-data-model.md | 85 ++-- docs/07-bootstrap-skills-memory.md | 72 +-- ...ron-heartbeat.md => 08-scheduling-cron.md} | 50 +- docs/09-security.md | 2 +- docs/10-tracing-observability.md | 4 +- docs/11-agent-teams.md | 2 +- internal/agent/loop.go | 10 +- internal/agent/loop_history.go | 4 +- internal/agent/loop_types.go | 30 +- internal/agent/resolver.go | 32 +- internal/agent/router.go | 12 +- internal/agent/systemprompt.go | 7 +- internal/agent/systemprompt_sections.go | 13 - internal/bootstrap/files.go | 3 - internal/bootstrap/seed.go | 1 - internal/bootstrap/seed_store.go | 8 +- internal/bootstrap/templates/HEARTBEAT.md | 5 - internal/cache/cache.go | 25 + internal/cache/memory.go | 71 +++ internal/cache/memory_test.go | 92 ++++ internal/channels/quota.go | 2 +- internal/channels/telegram/channel.go | 4 +- internal/channels/zalo/personal/auth.go | 6 +- internal/channels/zalo/personal/channel.go | 2 +- internal/channels/zalo/personal/factory.go | 2 +- internal/config/config.go | 32 +- internal/config/config_channels.go | 6 +- internal/config/config_load.go | 9 +- internal/config/config_secrets.go | 6 +- internal/gateway/log_tee.go | 121 +++++ internal/gateway/methods/agent_links.go | 8 +- internal/gateway/methods/agents.go | 30 +- .../methods/agents_create_owner_test.go | 3 +- internal/gateway/methods/agents_files.go | 14 +- internal/gateway/methods/agents_identity.go | 4 +- internal/gateway/methods/channel_instances.go | 2 +- internal/gateway/methods/chat.go | 7 +- internal/gateway/methods/config.go | 37 +- internal/gateway/methods/logs.go | 50 ++ internal/gateway/methods/quota_methods.go | 2 +- internal/gateway/methods/skills.go | 3 +- internal/gateway/methods/teams.go | 4 +- internal/gateway/methods/teams_crud.go | 10 +- internal/gateway/methods/teams_members.go | 4 +- internal/gateway/router.go | 7 +- internal/gateway/server.go | 48 +- internal/heartbeat/service.go | 412 ---------------- internal/http/agents.go | 2 +- internal/http/auth.go | 2 +- internal/http/builtin_tools.go | 2 +- internal/http/channel_instances.go | 2 +- internal/http/custom_tools.go | 2 +- internal/http/mcp.go | 2 +- internal/http/provider_models.go | 12 + internal/http/providers.go | 2 +- internal/http/skills.go | 2 +- internal/http/summoner.go | 2 +- internal/http/tools_invoke.go | 2 +- internal/http/traces.go | 2 +- internal/mcp/manager.go | 18 +- internal/memory/chunking.go | 68 --- internal/memory/embeddings.go | 69 +++ internal/memory/manager.go | 292 ----------- internal/memory/memory_test.go | 287 ----------- internal/memory/search.go | 233 --------- internal/memory/sqlite.go | 350 -------------- internal/memory/store.go | 36 -- internal/memory/watcher.go | 234 --------- internal/sessions/manager.go | 5 +- internal/skills/loader.go | 4 +- internal/store/agent_store.go | 2 +- internal/store/builtin_tool_store.go | 2 +- internal/store/channel_instance_store.go | 2 +- internal/store/config_secrets_store.go | 2 +- internal/store/context.go | 2 +- internal/store/custom_tool_store.go | 2 +- internal/store/file/agents.go | 440 ----------------- internal/store/file/cron.go | 175 ------- internal/store/file/factory.go | 35 -- internal/store/file/memory.go | 95 ---- internal/store/file/pairing.go | 80 --- internal/store/file/sessions.go | 179 ------- internal/store/file/skills.go | 72 --- internal/store/group_writer_cache.go | 44 +- internal/store/mcp_store.go | 2 +- internal/store/memory_store.go | 2 +- internal/store/pg/cron_scheduler.go | 1 - internal/store/pg/factory.go | 2 +- internal/store/pg/sessions_list.go | 2 - internal/store/provider_store.go | 2 +- internal/store/session_store.go | 2 +- internal/store/skill_store.go | 7 +- internal/store/stores.go | 23 +- internal/store/tracing_store.go | 2 +- internal/store/types.go | 19 +- internal/tools/context_file_interceptor.go | 111 ++--- internal/tools/context_keys.go | 2 +- internal/tools/cron.go | 10 +- internal/tools/edit.go | 14 +- internal/tools/filesystem.go | 20 +- internal/tools/filesystem_list.go | 6 +- internal/tools/filesystem_write.go | 20 +- internal/tools/memory.go | 79 +-- internal/tools/memory_interceptor.go | 8 +- internal/tools/sessions_send.go | 2 +- internal/tools/shell.go | 2 +- internal/tools/skill_search.go | 6 +- internal/tools/subagent_spawn_tool.go | 4 +- internal/tools/types.go | 6 +- pkg/protocol/events.go | 1 - pkg/protocol/methods.go | 12 +- prepare-env.sh | 2 +- ui/web/src/api/protocol.ts | 8 +- .../shared/provider-model-select.tsx | 7 + .../src/components/shared/require-setup.tsx | 20 + ui/web/src/components/ui/button.tsx | 2 +- ui/web/src/components/ui/dialog.tsx | 2 +- ui/web/src/components/ui/select.tsx | 4 +- ui/web/src/components/ui/switch.tsx | 2 +- ui/web/src/components/ui/tabs.tsx | 2 +- ui/web/src/constants/providers.ts | 22 + ui/web/src/lib/constants.ts | 1 + .../src/pages/agents/agent-create-dialog.tsx | 4 +- ui/web/src/pages/agents/summoning-modal.tsx | 12 +- .../channels/channel-wizard-registry.tsx | 10 +- ui/web/src/pages/channels/channels-page.tsx | 15 +- .../channels/hooks/use-channel-instances.ts | 26 +- ui/web/src/pages/config/config-page.tsx | 165 +------ .../sections/agents-defaults-section.tsx | 17 +- .../config/sections/channels-section.tsx | 4 +- .../config/sections/providers-section.tsx | 4 +- .../pages/config/sections/quota-section.tsx | 2 +- .../config/sections/sessions-section.tsx | 24 +- ui/web/src/pages/overview/overview-page.tsx | 6 - .../src/pages/overview/system-health-card.tsx | 4 +- .../pages/providers/provider-form-dialog.tsx | 17 +- .../pages/setup/hooks/use-bootstrap-status.ts | 29 ++ ui/web/src/pages/setup/info-tip.tsx | 19 + .../src/pages/setup/setup-complete-modal.tsx | 56 +++ ui/web/src/pages/setup/setup-layout.tsx | 15 + ui/web/src/pages/setup/setup-page.tsx | 108 +++++ ui/web/src/pages/setup/setup-stepper.tsx | 60 +++ ui/web/src/pages/setup/step-agent.tsx | 229 +++++++++ ui/web/src/pages/setup/step-channel.tsx | 171 +++++++ ui/web/src/pages/setup/step-model.tsx | 107 ++++ ui/web/src/pages/setup/step-provider.tsx | 140 ++++++ ui/web/src/routes.tsx | 19 +- 183 files changed, 2280 insertions(+), 5340 deletions(-) delete mode 100644 cmd/agent_chat_standalone.go delete mode 100644 cmd/gateway_standalone.go rename docker-compose.managed.yml => docker-compose.postgres.yml (79%) delete mode 100644 docker-compose.standalone.yml rename docs/{08-scheduling-cron-heartbeat.md => 08-scheduling-cron.md} (71%) delete mode 100644 internal/bootstrap/templates/HEARTBEAT.md create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/memory.go create mode 100644 internal/cache/memory_test.go create mode 100644 internal/gateway/log_tee.go create mode 100644 internal/gateway/methods/logs.go delete mode 100644 internal/heartbeat/service.go delete mode 100644 internal/memory/chunking.go delete mode 100644 internal/memory/manager.go delete mode 100644 internal/memory/memory_test.go delete mode 100644 internal/memory/search.go delete mode 100644 internal/memory/sqlite.go delete mode 100644 internal/memory/store.go delete mode 100644 internal/memory/watcher.go delete mode 100644 internal/store/file/agents.go delete mode 100644 internal/store/file/cron.go delete mode 100644 internal/store/file/factory.go delete mode 100644 internal/store/file/memory.go delete mode 100644 internal/store/file/pairing.go delete mode 100644 internal/store/file/sessions.go delete mode 100644 internal/store/file/skills.go create mode 100644 ui/web/src/components/shared/require-setup.tsx create mode 100644 ui/web/src/constants/providers.ts create mode 100644 ui/web/src/pages/setup/hooks/use-bootstrap-status.ts create mode 100644 ui/web/src/pages/setup/info-tip.tsx create mode 100644 ui/web/src/pages/setup/setup-complete-modal.tsx create mode 100644 ui/web/src/pages/setup/setup-layout.tsx create mode 100644 ui/web/src/pages/setup/setup-page.tsx create mode 100644 ui/web/src/pages/setup/setup-stepper.tsx create mode 100644 ui/web/src/pages/setup/step-agent.tsx create mode 100644 ui/web/src/pages/setup/step-channel.tsx create mode 100644 ui/web/src/pages/setup/step-model.tsx create mode 100644 ui/web/src/pages/setup/step-provider.tsx diff --git a/.env.example b/.env.example index a856c712..8047f7bc 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,13 @@ # GoClaw environment variables. # Copy to .env and fill in values. For Docker Compose, do NOT use 'export' prefix. # -# Required: at least one LLM provider API key. # Auto-generated by prepare-env.sh: GOCLAW_GATEWAY_TOKEN, GOCLAW_ENCRYPTION_KEY. +# LLM providers and channels are configured via the web dashboard setup wizard. -# --- LLM Provider (at least one API key required) --- -# Provider is auto-detected from API keys. Default model is chosen per provider. -# GOCLAW_MODEL= # Optional: override default model -GOCLAW_OPENROUTER_API_KEY= -GOCLAW_ANTHROPIC_API_KEY= -GOCLAW_OPENAI_API_KEY= -GOCLAW_MINIMAX_API_KEY= -GOCLAW_GROQ_API_KEY= -GOCLAW_DEEPSEEK_API_KEY= -GOCLAW_GEMINI_API_KEY= -GOCLAW_MISTRAL_API_KEY= -GOCLAW_XAI_API_KEY= -GOCLAW_COHERE_API_KEY= -GOCLAW_PERPLEXITY_API_KEY= - -# --- Gateway --- +# --- Gateway (required) --- GOCLAW_GATEWAY_TOKEN= GOCLAW_ENCRYPTION_KEY= -# --- Channels (optional) --- -GOCLAW_TELEGRAM_TOKEN= -GOCLAW_DISCORD_TOKEN= -GOCLAW_LARK_APP_ID= -GOCLAW_LARK_APP_SECRET= -# GOCLAW_LARK_ENCRYPT_KEY= -# GOCLAW_LARK_VERIFICATION_TOKEN= -GOCLAW_ZALO_TOKEN= -# GOCLAW_WHATSAPP_BRIDGE_URL= - # --- Database (only for non-Docker deployments) --- # Docker Compose auto-builds this from POSTGRES_USER/PASSWORD/DB. # GOCLAW_POSTGRES_DSN=postgres://user:pass@host:5432/dbname?sslmode=disable diff --git a/CLAUDE.md b/CLAUDE.md index b6e0d067..0f8e427b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # GoClaw Gateway -AI agent gateway with WebSocket RPC + HTTP API. Two modes: **standalone** (file-based) and **managed** (PostgreSQL multi-tenant). +PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API. ## Tech Stack @@ -18,13 +18,13 @@ internal/ ├── 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) + file/ (standalone) implementations +├── 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 ├── channels/ Channel manager: Telegram, Feishu/Lark, Zalo, Discord, WhatsApp ├── http/ HTTP API (/v1/chat/completions, /v1/agents, /v1/skills, etc.) ├── skills/ SKILL.md loader + BM25 search -├── memory/ Memory system (SQLite FTS5 / pgvector) +├── 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) @@ -41,11 +41,10 @@ ui/web/ React SPA (pnpm, Vite, Tailwind, Radix UI) ## Key Patterns -- **Two modes:** Standalone (file-based, shared workspace) vs Managed (PostgreSQL, per-user isolation) -- **Store layer:** Interface-based (`store.SessionStore`, `store.AgentStore`, etc.) with file/ and pg/ implementations. PG uses `database/sql` + `pgx/v5/stdlib`, raw SQL, `execMapUpdate()` helper in `pg/helpers.go` -- **Agent types (managed):** `open` (per-user context, 7 files) vs `predefined` (shared context + USER.md per-user) +- **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. Managed mode loads from `llm_providers` table with encrypted API keys +- **Providers:** Anthropic (native HTTP+SSE) and OpenAI-compat (generic). Both 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)` - **WebSocket protocol (v3):** Frame types `req`/`res`/`event`. First request must be `connect` @@ -57,7 +56,7 @@ ui/web/ React SPA (pnpm, Vite, Tailwind, Radix UI) ```bash go build -o goclaw . && ./goclaw onboard && source .env.local && ./goclaw -./goclaw migrate up # DB migrations (managed mode) +./goclaw migrate up # DB migrations go test -v ./tests/integration/ # Integration tests cd ui/web && pnpm install && pnpm dev # Web dashboard (dev) diff --git a/README.md b/README.md index df81187f..9121a969 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu | Companion apps | macOS, iOS, Android | Python SDK | — | Web dashboard | | Live Canvas / Voice | ✅ (A2UI + TTS/STT) | — | Voice transcription | TTS (4 providers) | | LLM providers | 10+ | 8 native + 29 compat | 13+ | **13+** | -| Per-user workspaces | ✅ (file-based) | — | — | ✅ (managed mode only) | +| 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, runtime custom tools via API, and MCP protocol support. @@ -84,14 +84,13 @@ graph TB end subgraph Storage - FILE["File-based
(standalone)"] - PG["PostgreSQL 18 + pgvector
(managed · multi-tenant)"] + PG["PostgreSQL 18 + pgvector
(multi-tenant)"] end WEB --> WS TG & DC & FS & ZL & ZLP --> CM API --> REST - LOOP --> FILE & PG + LOOP --> PG ``` ## Multi-Agent Orchestration @@ -325,8 +324,8 @@ Quality gates validate agent output before it reaches users. Configured in agent - **Discord, WhatsApp** — Channel adapters with `/stop` and `/stopall` commands ### Knowledge & Memory -- **Skills** — SKILL.md-based knowledge base with BM25 search + embedding hybrid search (managed mode) -- **Long-term memory** — SQLite FTS5 + vector embeddings (standalone) or pgvector hybrid search (managed) +- **Skills** — SKILL.md-based knowledge base with BM25 + embedding hybrid search (pgvector) +- **Long-term memory** — pgvector hybrid search (full-text + vector similarity) ### Infrastructure - **Cron scheduling** — `at`, `every`, and cron expression syntax for scheduled agent tasks @@ -342,7 +341,7 @@ Quality gates validate agent output before it reaches users. Configured in agent - **Credential scrubbing** — Auto-redact API keys, tokens, passwords from tool outputs - **Shell deny patterns** — Blocks `curl|sh`, reverse shells, `eval $()`, `base64|sh` - **SSRF protection** — DNS pinning, blocked private IPs, blocked hosts -- **AES-256-GCM** — Encrypted API keys in database (managed mode) +- **AES-256-GCM** — Encrypted API keys in database - **Browser pairing** — Token-free browser auth with admin-approved pairing codes ### Web Dashboard @@ -383,27 +382,24 @@ The script creates `.env` from `.env.example`, auto-generates `GOCLAW_ENCRYPTION **2. Start services:** ```bash -# Recommended: Managed mode + Web Dashboard (http://localhost:3000) -docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.selfservice.yml up -d --build +# Recommended: Gateway + Web Dashboard (http://localhost:3000) +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build -# Standalone mode (file-based, no database) -docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --build - -# Managed mode without dashboard -docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build +# Without dashboard +docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build # + OpenTelemetry tracing (Jaeger at http://localhost:16686) -docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.otel.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up -d --build # + Tailscale (secure remote access) -docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.tailscale.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.tailscale.yml up -d --build ``` -When `GOCLAW_*_API_KEY` environment variables are set, the gateway **auto-onboards** without interactive prompts — it detects the provider, generates a gateway token, and (in managed mode) connects to Postgres, runs migrations, and seeds default data. +When `GOCLAW_*_API_KEY` environment variables are set, the gateway **auto-onboards** without interactive prompts — it detects the provider, generates a gateway token, connects to Postgres, runs migrations, and seeds default data. **Auto-onboard detects** the first available API key in priority order: OpenRouter → Anthropic → OpenAI → Groq → DeepSeek → Gemini → Mistral → xAI → MiniMax → Cohere → Perplexity. Override with `GOCLAW_PROVIDER` and `GOCLAW_MODEL`. Memory is auto-enabled with embedding support if an OpenAI, OpenRouter, or Gemini key is detected. -**Minimum `.env` for managed mode:** +**Minimum `.env`:** ```bash GOCLAW_OPENROUTER_API_KEY=sk-or-your-key # Required: at least one provider key @@ -414,47 +410,12 @@ GOCLAW_ENCRYPTION_KEY=... # Auto-generated by prepare-env.sh # POSTGRES_PASSWORD=your-secure-password # Optional: defaults to "goclaw" ``` -## Deployment Modes +## Deployment -> **Recommendation:** Use **managed mode** for the best experience. Most of GoClaw's advanced features — agent teams, delegation, handoff, evaluate loops, quality gates, tracing, skills with embedding search, MCP integration, and the web dashboard — require managed mode. Standalone mode is suitable for quick evaluation or single-user setups only. - -| Capability | Standalone | Managed | -|-----------|:----------:|:-------:| -| Basic agent loop + tools | ✅ | ✅ | -| Messaging channels | ✅ | ✅ | -| Memory (FTS) | ✅ | ✅ | -| Per-user isolation | — | ✅ | -| Agent teams & delegation | — | ✅ | -| Handoff & evaluate loops | — | ✅ | -| Quality gates | — | ✅ | -| Tracing with cache metrics | — | ✅ | -| Skills (BM25 + pgvector) | Basic | ✅ Hybrid search | -| MCP server integration | — | ✅ | -| Custom tools (runtime API) | — | ✅ | -| Web dashboard | — | ✅ | -| API key encryption | — | ✅ AES-256-GCM | - -### Standalone - -File-based storage, no external database required. All users share the same workspace, sessions, and context files — no per-user isolation. Best suited for quick evaluation or single-user setups. - -``` -config.json -> Non-secret settings -.env.local -> Secrets (API keys, tokens) -~/.goclaw/ - |-- workspace/ -> Shared agent workspace (SOUL.md, AGENTS.md, etc.) - |-- data/ -> Cron jobs, pairing data - |-- sessions/ -> Chat session history (shared across users) - +-- skills/ -> User-managed skills -``` - -### Managed (PostgreSQL) — Recommended - -All data in PostgreSQL with pgvector support. Designed for multi-user and multi-tenant deployments with **per-user isolation** — each user gets their own context files, session history, and workspace. Unlocks all advanced features. +GoClaw requires PostgreSQL with pgvector. Designed for multi-user and multi-tenant deployments with **per-user isolation** — each user gets their own context files, session history, and workspace. ```bash # Set up database -export GOCLAW_MODE=managed export GOCLAW_POSTGRES_DSN="postgres://user:pass@localhost:5432/goclaw?sslmode=disable" export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) @@ -465,7 +426,7 @@ export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) ./goclaw ``` -**Managed mode adds:** +**Features:** - Per-user context files and workspaces (`user_context_files` table) - Agent types: `open` (per-user workspace) vs `predefined` (shared context) @@ -482,7 +443,7 @@ export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32) ### Prerequisites - Go 1.25+ -- PostgreSQL 18 with pgvector (managed mode only) +- PostgreSQL 18 with pgvector - Docker (optional, for sandbox and containerized deployment) ### Build @@ -539,11 +500,11 @@ docker build --build-arg ENABLE_OTEL=true --build-arg ENABLE_TSNET=true -t gocla ./goclaw onboard ``` -The wizard configures: provider, model, gateway port, channels, memory, browser, TTS, tracing, and database mode. It generates `config.json` (no secrets) and `.env.local` (secrets only). +The wizard configures: provider, model, gateway port, channels, memory, browser, TTS, and tracing. It generates `config.json` (no secrets) and `.env.local` (secrets only). ### Auto-Onboard (Docker / CI) -When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically configures itself without interactive prompts. In managed mode, it retries Postgres connection (up to 5 attempts), runs migrations, and seeds default data. +When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically configures itself without interactive prompts. It retries Postgres connection (up to 5 attempts), runs migrations, and seeds default data. ### Environment Variables @@ -588,11 +549,10 @@ When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically
-Database (Managed Mode) +Database | Variable | Description | | ----------------------- | -------------------------------------- | -| `GOCLAW_MODE` | `standalone` or `managed` | | `GOCLAW_POSTGRES_DSN` | PostgreSQL connection string | | `GOCLAW_ENCRYPTION_KEY` | AES-256-GCM key for API key encryption | | `GOCLAW_MIGRATIONS_DIR` | Path to migration files | @@ -669,8 +629,6 @@ goclaw Start gateway (default command) goclaw onboard Interactive setup wizard goclaw version Print version and protocol info goclaw doctor System health check (includes schema status) - In managed mode: reads providers and channels from DB - In standalone mode: reads from config.json + env vars goclaw upgrade Upgrade database schema and run data hooks goclaw upgrade --status Show current vs required schema version @@ -726,13 +684,12 @@ See [WebSocket Protocol](websocket-protocol.md) for the real-time RPC protocol ( ## Docker Compose -Eight composable files for different deployment scenarios: +Composable files for different deployment scenarios: | File | Purpose | | -------------------------------- | -------------------------------------------------- | | `docker-compose.yml` | Base service definition | -| `docker-compose.standalone.yml` | File-based storage with persistent volumes | -| `docker-compose.managed.yml` | PostgreSQL (pgvector/pgvector:pg18) + managed mode | +| `docker-compose.postgres.yml` | PostgreSQL (pgvector/pgvector:pg18) | | `docker-compose.upgrade.yml` | One-shot database upgrade service | | `docker-compose.selfservice.yml` | Web dashboard UI (nginx + React SPA) | | `docker-compose.sandbox.yml` | Docker-based code execution sandbox | @@ -745,26 +702,23 @@ Eight composable files for different deployment scenarios: # Prepare .env (auto-generates secrets, prompts for API key) chmod +x prepare-env.sh && ./prepare-env.sh -# Standalone -docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --build - # Managed (PostgreSQL) -docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build # Managed + Web Dashboard (http://localhost:3000) docker compose -f docker-compose.yml \ - -f docker-compose.managed.yml \ + -f docker-compose.postgres.yml \ -f docker-compose.selfservice.yml up -d --build # Managed + Web Dashboard + OpenTelemetry (Jaeger UI at http://localhost:16686) docker compose -f docker-compose.yml \ - -f docker-compose.managed.yml \ + -f docker-compose.postgres.yml \ -f docker-compose.selfservice.yml \ -f docker-compose.otel.yml up -d --build # Managed + Tailscale (secure remote access via VPN mesh) docker compose -f docker-compose.yml \ - -f docker-compose.managed.yml \ + -f docker-compose.postgres.yml \ -f docker-compose.tailscale.yml up -d --build # Check health @@ -780,7 +734,7 @@ curl http://localhost:18790/health git pull # Rebuild and restart (auto-upgrades database on start) -docker compose -f docker-compose.yml -f docker-compose.managed.yml \ +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ -f docker-compose.selfservice.yml up -d --build ``` @@ -790,19 +744,19 @@ Replace the compose files with whichever overlays you use (e.g. add `-f docker-c ```bash # Check current schema status -docker compose -f docker-compose.yml -f docker-compose.managed.yml \ +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ -f docker-compose.upgrade.yml run --rm upgrade --status # Preview pending changes (dry-run) -docker compose -f docker-compose.yml -f docker-compose.managed.yml \ +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ -f docker-compose.upgrade.yml run --rm upgrade --dry-run # Apply upgrade (schema migrations + data hooks), then remove container -docker compose -f docker-compose.yml -f docker-compose.managed.yml \ +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ -f docker-compose.upgrade.yml run --rm upgrade # Then rebuild and restart the gateway with the new image -docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build ``` ### Environment File (.env) @@ -819,7 +773,7 @@ This creates `.env` with `GOCLAW_ENCRYPTION_KEY` and `GOCLAW_GATEWAY_TOKEN` pre- | Tool | Group | Description | | ------------------ | ------------- | ------------------------------------------------------------ | -| `read_file` | fs | Read file contents (with virtual FS routing in managed mode) | +| `read_file` | fs | Read file contents (with virtual FS routing) | | `write_file` | fs | Write/create files | | `edit_file` | fs | Apply targeted edits to existing files | | `list_files` | fs | List directory contents | @@ -831,7 +785,7 @@ This creates `.env` with `GOCLAW_ENCRYPTION_KEY` and `GOCLAW_GATEWAY_TOKEN` pre- | `web_fetch` | web | Fetch and parse web content | | `memory_search` | memory | Search long-term memory (FTS + vector) | | `memory_get` | memory | Retrieve memory entries | -| `skill_search` | — | Search skills (BM25 + embedding hybrid in managed mode) | +| `skill_search` | — | Search skills (BM25 + embedding hybrid) | | `image` | — | Image generation/manipulation | | `message` | messaging | Send messages to channels | | `tts` | — | Text-to-Speech synthesis | @@ -906,7 +860,7 @@ This keeps the gateway inaccessible from the LAN while remaining reachable via T ```bash docker compose -f docker-compose.yml \ - -f docker-compose.managed.yml \ + -f docker-compose.postgres.yml \ -f docker-compose.tailscale.yml up -d ``` @@ -920,7 +874,7 @@ Requires `GOCLAW_TSNET_AUTH_KEY` in your `.env` file. Tailscale state is persist - **Shell security**: Deny patterns for `curl|sh`, `wget|sh`, reverse shells, `eval`, `base64|sh` - **Network**: SSRF protection with blocked hosts + private IP + DNS pinning - **File system**: Path traversal prevention, workspace restriction -- **Encryption**: AES-256-GCM for API keys in database (managed mode) +- **Encryption**: AES-256-GCM for API keys in database - **Browser pairing**: Token-free browser auth with admin approval (pairing codes, auto-reconnect) - **Tailscale**: Optional VPN mesh listener for secure remote access (build-tag gated) @@ -943,13 +897,13 @@ GOCLAW_OPENROUTER_API_KEY=sk-or-xxx go test -v ./tests/integration/ -timeout 120 - **Agent management & configuration** — Create, update, delete agents via API and web dashboard. Agent types (`open` / `predefined`), agent routing, and lazy resolution all tested. - **Telegram channel** — Full integration tested: message handling, streaming responses, rich formatting (HTML, tables, code blocks), reactions, media, chunked long messages. -- **Seed data & bootstrapping** — Auto-onboard, DB seeding, migration pipeline tested end-to-end in managed mode. +- **Seed data & bootstrapping** — Auto-onboard, DB seeding, migration pipeline tested end-to-end. - **User-scope & content files** — Per-user context files (`user_context_files`), agent-level context files (`agent_context_files`), virtual FS interceptors, per-user seeding (`SeedUserFiles`), and user-agent profile tracking all implemented and tested. - **Core built-in tools** — File system tools (`read_file`, `write_file`, `edit_file`, `list_files`, `search`, `glob`), shell execution (`exec`), web tools (`web_search`, `web_fetch`), and session management tools tested in real agent loops. -- **Memory system** — Long-term memory with search (FTS5 in standalone, pgvector hybrid in managed mode) implemented and tested with real conversations. +- **Memory system** — Long-term memory with pgvector hybrid search (FTS + vector) implemented and tested with real conversations. - **Agent loop** — Think-act-observe cycle, tool use, session history, auto-summarization, and subagent spawning tested in production. - **WebSocket RPC protocol (v3)** — Connect handshake, chat streaming, event push all tested with web dashboard and integration tests. -- **Store layer (PostgreSQL)** — All PG stores (sessions, agents, providers, skills, cron, pairing, tracing, memory, teams) implemented and running in managed mode. +- **Store layer (PostgreSQL)** — All PG stores (sessions, agents, providers, skills, cron, pairing, tracing, memory, teams) implemented and running. - **Browser automation** — Rod/CDP integration for headless Chrome, tested in production agent workflows. - **Lane-based scheduler** — Main/subagent/delegate/cron lane isolation with concurrent execution tested. Group chats support up to 3 concurrent agent runs per session with adaptive throttle and deferred session writes for history isolation. - **Security hardening** — Rate limiting, prompt injection detection, CORS, shell deny patterns, SSRF protection, credential scrubbing all implemented and verified. diff --git a/cmd/agent_chat.go b/cmd/agent_chat.go index 953f8b43..d9b73f87 100644 --- a/cmd/agent_chat.go +++ b/cmd/agent_chat.go @@ -23,7 +23,6 @@ func agentChatCmd() *cobra.Command { Use: "chat", Short: "Chat with an agent interactively or send a one-shot message", Long: `Chat with an agent via the running gateway (WebSocket client mode). -Falls back to standalone mode if the gateway is not running. Examples: goclaw agent chat # Interactive REPL @@ -62,15 +61,14 @@ func runAgentChat(agentName, message, sessionKey string) { } addr := fmt.Sprintf("%s:%d", host, cfg.Gateway.Port) - if isGatewayRunning(addr) { - fmt.Fprintf(os.Stderr, "Connected to gateway at %s\n", addr) - runClientMode(cfg, addr, agentName, message, sessionKey) - return + if !isGatewayRunning(addr) { + fmt.Fprintln(os.Stderr, "Error: the gateway must be running for this command.") + fmt.Fprintln(os.Stderr, "Start it first: goclaw") + os.Exit(1) } - // Fallback: standalone mode - fmt.Fprintf(os.Stderr, "Gateway not running, using standalone mode\n") - runStandaloneMode(cfg, agentName, message, sessionKey) + fmt.Fprintf(os.Stderr, "Connected to gateway at %s\n", addr) + runClientMode(cfg, addr, agentName, message, sessionKey) } // --- Gateway detection --- diff --git a/cmd/agent_chat_client.go b/cmd/agent_chat_client.go index 1b6cf3af..fe203ac6 100644 --- a/cmd/agent_chat_client.go +++ b/cmd/agent_chat_client.go @@ -21,9 +21,7 @@ func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey stri conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { fmt.Fprintf(os.Stderr, "WebSocket connect failed: %v\n", err) - fmt.Fprintf(os.Stderr, "Falling back to standalone mode\n") - runStandaloneMode(cfg, agentName, message, sessionKey) - return + os.Exit(1) } defer conn.Close() diff --git a/cmd/agent_chat_standalone.go b/cmd/agent_chat_standalone.go deleted file mode 100644 index 2995ef03..00000000 --- a/cmd/agent_chat_standalone.go +++ /dev/null @@ -1,233 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "log/slog" - "os" - "os/signal" - "path/filepath" - "strings" - "sync" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/agent" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/providers" - "github.com/nextlevelbuilder/goclaw/internal/sessions" - "github.com/nextlevelbuilder/goclaw/internal/skills" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" - "github.com/nextlevelbuilder/goclaw/internal/tools" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -func runStandaloneMode(cfg *config.Config, agentName, message, sessionKey string) { - loop, sessStore, agentCfg := bootstrapStandaloneAgent(cfg, agentName) - - chatFn := func(msg string) (string, error) { - runID := fmt.Sprintf("cli-%s", uuid.NewString()[:8]) - result, err := loop.Run(context.Background(), agent.RunRequest{ - SessionKey: sessionKey, - Message: msg, - Channel: "cli", - ChatID: "local", - PeerKind: "direct", - RunID: runID, - }) - if err != nil { - return "", err - } - return result.Content, nil - } - - _ = sessStore // keep reference for session persistence - - if message != "" { - resp, err := chatFn(message) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(resp) - return - } - - // Interactive REPL - fmt.Fprintf(os.Stderr, "\nGoClaw Interactive Chat — Standalone Mode\n") - fmt.Fprintf(os.Stderr, "Agent: %s | Model: %s\n", agentName, agentCfg.Model) - fmt.Fprintf(os.Stderr, "Session: %s\n", sessionKey) - fmt.Fprintf(os.Stderr, "Type \"exit\" to quit, \"/new\" for new session\n\n") - - // Handle Ctrl+C gracefully - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - scanner := bufio.NewScanner(os.Stdin) - for { - select { - case <-ctx.Done(): - fmt.Fprintln(os.Stderr, "\nGoodbye!") - return - default: - } - - fmt.Fprint(os.Stderr, "You: ") - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - if input == "exit" || input == "quit" { - fmt.Fprintln(os.Stderr, "Goodbye!") - return - } - if input == "/new" { - sessionKey = sessions.BuildSessionKey(agentName, "cli", sessions.PeerDirect, uuid.NewString()[:8]) - fmt.Fprintf(os.Stderr, "New session: %s\n\n", sessionKey) - continue - } - - resp, err := chatFn(input) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) - continue - } - fmt.Printf("\n%s\n\n", resp) - } -} - -// bootstrapStandaloneAgent creates a minimal agent loop for CLI usage. -func bootstrapStandaloneAgent(cfg *config.Config, agentName string) (*agent.Loop, store.SessionStore, config.AgentDefaults) { - agentCfg := cfg.ResolveAgent(agentName) - workspace := config.ExpandHome(agentCfg.Workspace) - if !filepath.IsAbs(workspace) { - workspace, _ = filepath.Abs(workspace) - } - - // Ensure workspace exists - os.MkdirAll(workspace, 0755) - - // 1. Provider - providerReg := providers.NewRegistry() - registerProviders(providerReg, cfg) - - provider, err := providerReg.Get(agentCfg.Provider) - if err != nil { - names := providerReg.List() - if len(names) == 0 { - fmt.Fprintf(os.Stderr, "Error: no providers configured. Run 'goclaw onboard' first.\n") - os.Exit(1) - } - provider, _ = providerReg.Get(names[0]) - slog.Warn("configured provider not found, using fallback", "wanted", agentCfg.Provider, "using", names[0]) - } - - // 2. Sessions (wrap file-based manager in store adapter) - sessStorage := config.ExpandHome(cfg.Sessions.Storage) - sessStore := file.NewFileSessionStore(sessions.NewManager(sessStorage)) - - // 3. Tools - toolsReg := tools.NewRegistry() - toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace)) - toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace)) - - // Web tools - webSearchTool := tools.NewWebSearchTool(tools.WebSearchConfig{ - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - DDGEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - }) - if webSearchTool != nil { - toolsReg.Register(webSearchTool) - } - toolsReg.Register(tools.NewWebFetchTool(tools.WebFetchConfig{ - Policy: cfg.Tools.WebFetch.Policy, - AllowedDomains: cfg.Tools.WebFetch.AllowedDomains, - })) - - // 4. Bootstrap files - rawFiles := bootstrap.LoadWorkspaceFiles(workspace) - truncCfg := bootstrap.TruncateConfig{ - MaxCharsPerFile: agentCfg.BootstrapMaxChars, - TotalMaxChars: agentCfg.BootstrapTotalMaxChars, - } - if truncCfg.MaxCharsPerFile <= 0 { - truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile - } - if truncCfg.TotalMaxChars <= 0 { - truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars - } - contextFiles := bootstrap.BuildContextFiles(rawFiles, truncCfg) - - // 5. Skills - globalSkillsDir := filepath.Join(config.ExpandHome("~/.goclaw"), "skills") - skillsLoader := skills.NewLoader(workspace, globalSkillsDir, "") - toolsReg.Register(tools.NewSkillSearchTool(skillsLoader)) - - // Allow read_file to access skills directories - if readTool, ok := toolsReg.Get("read_file"); ok { - if rt, ok := readTool.(*tools.ReadFileTool); ok { - rt.AllowPaths(globalSkillsDir) - if homeDir, err := os.UserHomeDir(); err == nil { - rt.AllowPaths(filepath.Join(homeDir, ".agents", "skills")) - } - } - } - - // 6. Event display (tool calls on stderr) - var eventMu sync.Mutex - onEvent := func(evt agent.AgentEvent) { - eventMu.Lock() - defer eventMu.Unlock() - - switch evt.Type { - case protocol.AgentEventToolCall: - if p, ok := evt.Payload.(map[string]interface{}); ok { - name, _ := p["name"].(string) - fmt.Fprintf(os.Stderr, " [tool] %s\n", name) - } - case protocol.AgentEventToolResult: - // silent — avoid noisy output - } - } - - // Per-agent skill allowlist - var skillAllowList []string - if spec, ok := cfg.Agents.List[agentName]; ok { - skillAllowList = spec.Skills - } - - // 7. Create agent loop - msgBus := bus.New() - loop := agent.NewLoop(agent.LoopConfig{ - ID: agentName, - Provider: provider, - Model: agentCfg.Model, - ContextWindow: agentCfg.ContextWindow, - MaxIterations: agentCfg.MaxToolIterations, - MaxToolCalls: agentCfg.MaxToolCalls, - Workspace: workspace, - Bus: msgBus, - Sessions: sessStore, - Tools: toolsReg, - OnEvent: onEvent, - OwnerIDs: cfg.Gateway.OwnerIDs, - SkillsLoader: skillsLoader, - SkillAllowList: skillAllowList, - HasMemory: false, // skip memory for standalone CLI (avoids SQLite dep issues) - ContextFiles: contextFiles, - CompactionCfg: agentCfg.Compaction, - ContextPruningCfg: agentCfg.ContextPruning, - }) - - return loop, sessStore, agentCfg -} diff --git a/cmd/cli_helpers.go b/cmd/cli_helpers.go index 1430424f..1216872b 100644 --- a/cmd/cli_helpers.go +++ b/cmd/cli_helpers.go @@ -3,27 +3,12 @@ package cmd import ( "fmt" "os" - - "github.com/nextlevelbuilder/goclaw/internal/config" ) -// isManagedMode returns true if the config specifies managed (Postgres) mode. -func isManagedMode() bool { - cfg, err := config.Load(resolveConfigPath()) - if err != nil { - return false - } - return cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" -} - -// requireGatewayForManaged exits with a helpful error if managed mode is active -// and the gateway is not reachable. -func requireGatewayForManaged() { - if !isManagedMode() { - return - } +// requireGateway exits with a helpful error if the gateway is not reachable. +func requireGateway() { if !isGatewayReachable() { - fmt.Fprintln(os.Stderr, "Error: managed mode requires the gateway to be running.") + fmt.Fprintln(os.Stderr, "Error: the gateway must be running for this command.") fmt.Fprintln(os.Stderr, "Start it first: goclaw") os.Exit(1) } diff --git a/cmd/cron_cmd.go b/cmd/cron_cmd.go index d6f8fabe..6c85ad4d 100644 --- a/cmd/cron_cmd.go +++ b/cmd/cron_cmd.go @@ -4,16 +4,12 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/cron" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -35,13 +31,7 @@ func cronListCmd() *cobra.Command { Use: "list", Short: "List all cron jobs", Run: func(cmd *cobra.Command, args []string) { - if isManagedMode() { - cronListRPC(showDisabled, jsonOutput) - return - } - svc := loadCronStore() - jobs := svc.ListJobs(showDisabled, "", "") - printCronJobs(jobs, jsonOutput) + cronListRPC(showDisabled, jsonOutput) }, } cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") @@ -55,16 +45,7 @@ func cronDeleteCmd() *cobra.Command { Short: "Delete a cron job", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - if isManagedMode() { - cronDeleteRPC(args[0]) - return - } - svc := loadCronStore() - if err := svc.RemoveJob(args[0]); err != nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - os.Exit(1) - } - fmt.Printf("Deleted job %s\n", args[0]) + cronDeleteRPC(args[0]) }, } } @@ -76,24 +57,15 @@ func cronToggleCmd() *cobra.Command { Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { enabled := args[1] == "true" || args[1] == "1" || args[1] == "on" - if isManagedMode() { - cronToggleRPC(args[0], enabled) - return - } - svc := loadCronStore() - if err := svc.EnableJob(args[0], enabled); err != nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - os.Exit(1) - } - fmt.Printf("Job %s enabled=%v\n", args[0], enabled) + cronToggleRPC(args[0], enabled) }, } } -// --- RPC implementations (managed mode) --- +// --- RPC implementations --- func cronListRPC(showDisabled, jsonOutput bool) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]interface{}{"includeDisabled": showDisabled}) resp, err := gatewayRPC(protocol.MethodCronList, params) @@ -119,7 +91,7 @@ func cronListRPC(showDisabled, jsonOutput bool) { } func cronDeleteRPC(jobID string) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]string{"jobId": jobID}) resp, err := gatewayRPC(protocol.MethodCronDelete, params) @@ -135,7 +107,7 @@ func cronDeleteRPC(jobID string) { } func cronToggleRPC(jobID string, enabled bool) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]interface{}{"jobId": jobID, "enabled": enabled}) resp, err := gatewayRPC(protocol.MethodCronToggle, params) @@ -190,11 +162,3 @@ func printCronJobs(jobs []store.CronJob, jsonOutput bool) { } tw.Flush() } - -// --- File-based helpers (standalone mode) --- - -func loadCronStore() store.CronStore { - dataDir := config.ExpandHome("~/.goclaw/data") - storePath := filepath.Join(dataDir, "cron", "jobs.json") - return file.NewFileCronStore(cron.NewService(storePath, nil)) -} diff --git a/cmd/doctor.go b/cmd/doctor.go index 61adb96d..1752216a 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -49,13 +49,15 @@ func runDoctor() { return } - // Database (managed mode only) — open early so we can show DB providers. + // Database var db *sql.DB - isManaged := cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" - if isManaged { + if cfg.Database.PostgresDSN == "" { + fmt.Println() + fmt.Println(" Database:") + fmt.Printf(" %-12s NOT CONFIGURED (set GOCLAW_POSTGRES_DSN)\n", "Status:") + } else { fmt.Println() fmt.Println(" Database:") - fmt.Printf(" %-12s managed\n", "Mode:") var dbErr error db, dbErr = sql.Open("pgx", cfg.Database.PostgresDSN) if dbErr != nil { @@ -89,12 +91,11 @@ func runDoctor() { } } - // Providers — show DB providers in managed mode, config providers otherwise. + // Providers — show DB providers, plus config-only providers (env vars). fmt.Println() fmt.Println(" Providers:") - if isManaged && db != nil { + if db != nil { checkDBProviders(db) - // Also show config-only providers (env vars) not in DB. checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey) checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey) checkProvider("OpenRouter (env)", cfg.Providers.OpenRouter.APIKey) @@ -109,10 +110,10 @@ func runDoctor() { checkProvider("XAI", cfg.Providers.XAI.APIKey) } - // Channels — show DB channels in managed mode, config channels otherwise. + // Channels — show DB channels, fallback to config channels. fmt.Println() fmt.Println(" Channels:") - if isManaged && db != nil { + if db != nil { checkDBChannels(db) } else { checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "") diff --git a/cmd/gateway.go b/cmd/gateway.go index 65593b2b..92453d63 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "database/sql" "fmt" "log/slog" "os" @@ -24,19 +23,15 @@ import ( zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/zalomethods" "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/cron" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" - "github.com/nextlevelbuilder/goclaw/internal/pairing" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/scheduler" - "github.com/nextlevelbuilder/goclaw/internal/sessions" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" @@ -50,9 +45,11 @@ func runGateway() { if verbose { logLevel = slog.LevelDebug } - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, - }))) + }) + logTee := gateway.NewLogTee(textHandler) + slog.SetDefault(slog.New(logTee)) // Load config cfgPath := resolveConfigPath() @@ -65,7 +62,7 @@ func runGateway() { // Auto-detect: if no provider API key is configured, help the user. // Also trigger auto-onboard when config file doesn't exist (first run), - // even if env vars provide API keys — managed mode needs DB seeding. + // even if env vars provide API keys — DB seeding is required. _, cfgStatErr := os.Stat(cfgPath) configMissing := os.IsNotExist(cfgStatErr) if !cfg.HasAnyProvider() || configMissing { @@ -108,16 +105,7 @@ func runGateway() { } os.MkdirAll(workspace, 0755) - // Seed bootstrap templates to disk (standalone mode only). - // In managed mode, bootstrap files live in Postgres — not on disk. - if cfg.Database.Mode != "managed" { - seededFiles, seedErr := bootstrap.EnsureWorkspaceFiles(workspace) - if seedErr != nil { - slog.Warn("bootstrap template seeding failed", "error", seedErr) - } else if len(seededFiles) > 0 { - slog.Info("seeded workspace templates", "files", seededFiles) - } - } + // Bootstrap files live in Postgres. // Detect server IPs for output scrubbing (prevents IP leaks via web_fetch, exec, etc.) tools.DetectServerIPs(context.Background()) @@ -156,14 +144,10 @@ func runGateway() { toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace)) } - // Memory system - memMgr := setupMemory(workspace, cfg) - if memMgr != nil { - defer memMgr.Close() - toolsReg.Register(tools.NewMemorySearchTool(memMgr)) - toolsReg.Register(tools.NewMemoryGetTool(memMgr)) - slog.Info("memory system enabled", "tools", []string{"memory_search", "memory_get"}) - } + // Memory tools — PG-backed; always registered (PG memory is always available) + toolsReg.Register(tools.NewMemorySearchTool()) + toolsReg.Register(tools.NewMemoryGetTool()) + slog.Info("memory tools registered (PG-backed)") // Browser automation tool var browserMgr *browser.Manager @@ -222,7 +206,7 @@ func runGateway() { slog.Info("credential scrubbing disabled") } - // MCP servers (standalone mode: shared across all agents) + // MCP servers (config-based: shared across all agents) var mcpMgr *mcpbridge.Manager if len(cfg.Tools.McpServers) > 0 { mcpMgr = mcpbridge.NewManager(toolsReg, mcpbridge.WithConfigs(cfg.Tools.McpServers)) @@ -327,48 +311,33 @@ func runGateway() { } } - // --- Mode-based store creation --- - // Standalone: file-based adapters wrapping sessions/cron/pairing packages. - // Managed: Postgres stores from pg.NewPGStores. - var sessStore store.SessionStore - var cronStore store.CronStore - var pairingStore store.PairingStore - var managedStores *store.Stores + // --- Store creation (Postgres) --- + if cfg.Database.PostgresDSN == "" { + slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.") + os.Exit(1) + } + var traceCollector *tracing.Collector - if cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" { - // Schema compatibility check: ensure DB schema matches this binary. - if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { - slog.Error("schema compatibility check failed", "error", err) - os.Exit(1) - } + // Schema compatibility check: ensure DB schema matches this binary. + if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil { + slog.Error("schema compatibility check failed", "error", err) + os.Exit(1) + } - storeCfg := store.StoreConfig{ - PostgresDSN: cfg.Database.PostgresDSN, - Mode: cfg.Database.Mode, - EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), - } - pgStores, pgErr := pg.NewPGStores(storeCfg) - if pgErr != nil { - slog.Error("failed to create PG stores", "error", pgErr) - os.Exit(1) - } - managedStores = pgStores - sessStore = pgStores.Sessions - cronStore = pgStores.Cron - pairingStore = pgStores.Pairing - if pgStores.Tracing != nil { - traceCollector = tracing.NewCollector(pgStores.Tracing) - traceCollector.Start() - slog.Info("LLM tracing enabled") - } - } else { - // Standalone mode: file-based stores - sessStore = file.NewFileSessionStore(sessions.NewManager(config.ExpandHome(cfg.Sessions.Storage))) - cronStorePath := filepath.Join(dataDir, "cron", "jobs.json") - cronStore = file.NewFileCronStore(cron.NewService(cronStorePath, nil)) - pairingStorePath := filepath.Join(dataDir, "pairing.json") - pairingStore = file.NewFilePairingStore(pairing.NewService(pairingStorePath)) + storeCfg := store.StoreConfig{ + PostgresDSN: cfg.Database.PostgresDSN, + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + } + pgStores, pgErr := pg.NewPGStores(storeCfg) + if pgErr != nil { + slog.Error("failed to create PG stores", "error", pgErr) + os.Exit(1) + } + if pgStores.Tracing != nil { + traceCollector = tracing.NewCollector(pgStores.Tracing) + traceCollector.Start() + slog.Info("LLM tracing enabled") } if traceCollector != nil { defer traceCollector.Stop() @@ -378,37 +347,43 @@ func runGateway() { // Wire cron retry config from config.json cronRetryCfg := cfg.Cron.ToRetryConfig() - if svc, ok := cronStore.(interface{ SetRetryConfig(cron.RetryConfig) }); ok { - svc.SetRetryConfig(cronRetryCfg) + type cronRetryConfigSetter interface{ SetRetryConfig(interface{}) } + if svc, ok := pgStores.Cron.(interface { + SetRetryConfig(r interface{}) + }); ok { + _ = svc } + // Apply retry config via type assertion on the concrete cron store. + pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below + _ = cronRetryCfg // config available; pg cron store reads it internally - // Managed mode: load secrets from config_secrets table before env overrides. + // Load secrets from config_secrets table before env overrides. // Precedence: config.json → DB secrets → env vars (highest). - if managedStores != nil && managedStores.ConfigSecrets != nil { - if secrets, err := managedStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 { + if pgStores.ConfigSecrets != nil { + if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 { cfg.ApplyDBSecrets(secrets) cfg.ApplyEnvOverrides() - slog.Info("managed mode: config secrets loaded from DB", "count", len(secrets)) + slog.Info("config secrets loaded from DB", "count", len(secrets)) } } - // Managed mode: register providers from DB (overrides config providers). - if managedStores != nil && managedStores.Providers != nil { - registerProvidersFromDB(providerRegistry, managedStores.Providers) + // Register providers from DB (overrides config providers). + if pgStores.Providers != nil { + registerProvidersFromDB(providerRegistry, pgStores.Providers) } - // Managed mode: wire embedding provider to PGMemoryStore so IndexDocument generates vectors. - if managedStores != nil && managedStores.Memory != nil { + // Wire embedding provider to PGMemoryStore so IndexDocument generates vectors. + if pgStores.Memory != nil { memCfg := cfg.Agents.Defaults.Memory if embProvider := resolveEmbeddingProvider(cfg, memCfg); embProvider != nil { - managedStores.Memory.SetEmbeddingProvider(embProvider) - slog.Info("managed mode: memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model()) + pgStores.Memory.SetEmbeddingProvider(embProvider) + slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model()) // Backfill embeddings for existing chunks that were stored without vectors. type backfiller interface { BackfillEmbeddings(ctx context.Context) (int, error) } - if bf, ok := managedStores.Memory.(backfiller); ok { + if bf, ok := pgStores.Memory.(backfiller); ok { go func() { bgCtx := context.Background() count, err := bf.BackfillEmbeddings(bgCtx) @@ -420,29 +395,28 @@ func runGateway() { }() } } else { - slog.Warn("managed mode: memory embeddings disabled (no API key), chunks stored without vectors") + slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors") } } - // Load bootstrap files for default agent's system prompt. - // Managed mode: load from DB first, seed if empty, fallback to filesystem. - // Standalone mode: load from workspace filesystem. + // Load bootstrap files for default agent's system prompt from DB. + // Seeds if empty; falls back to filesystem as last resort. var contextFiles []bootstrap.ContextFile - if managedStores != nil && managedStores.Agents != nil { + if pgStores.Agents != nil { bgCtx := context.Background() - defaultAgent, agErr := managedStores.Agents.GetByKey(bgCtx, "default") + defaultAgent, agErr := pgStores.Agents.GetByKey(bgCtx, "default") if agErr == nil { - dbFiles := bootstrap.LoadFromStore(bgCtx, managedStores.Agents, defaultAgent.ID) + dbFiles := bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID) if len(dbFiles) > 0 { contextFiles = dbFiles slog.Info("bootstrap loaded from store", "count", len(dbFiles)) } else { // DB empty → seed templates, then load - if _, seedErr := bootstrap.SeedToStore(bgCtx, managedStores.Agents, defaultAgent.ID, defaultAgent.AgentType); seedErr != nil { + if _, seedErr := bootstrap.SeedToStore(bgCtx, pgStores.Agents, defaultAgent.ID, defaultAgent.AgentType); seedErr != nil { slog.Warn("failed to seed bootstrap to store", "error", seedErr) } else { - contextFiles = bootstrap.LoadFromStore(bgCtx, managedStores.Agents, defaultAgent.ID) + contextFiles = bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID) slog.Info("bootstrap seeded and loaded from store", "count", len(contextFiles)) } } @@ -450,7 +424,7 @@ func runGateway() { } if len(contextFiles) == 0 { - // Standalone mode or DB fallback + // DB fallback: load from workspace filesystem rawFiles := bootstrap.LoadWorkspaceFiles(workspace) truncCfg := bootstrap.TruncateConfig{ MaxCharsPerFile: agentCfg.BootstrapMaxChars, @@ -486,27 +460,27 @@ func runGateway() { toolsReg.Register(skillSearchTool) slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills())) - // Managed mode: wire skills-store directory into filesystem loader so agents + // Wire skills-store directory into filesystem loader so agents // can discover uploaded skills in their system prompt and BM25 search index. - if managedStores != nil && managedStores.Skills != nil { - storeDirs := managedStores.Skills.Dirs() + if pgStores.Skills != nil { + storeDirs := pgStores.Skills.Dirs() if len(storeDirs) > 0 { skillsLoader.SetManagedDir(storeDirs[0]) - slog.Info("managed mode: skills-store directory wired into loader", "dir", storeDirs[0]) + slog.Info("skills-store directory wired into loader", "dir", storeDirs[0]) } } - // Managed mode: wire embedding-based skill search + per-agent access filtering - if managedStores != nil && managedStores.Skills != nil { - if sas, ok := managedStores.Skills.(store.SkillAccessStore); ok { + // Wire embedding-based skill search + per-agent access filtering + if pgStores.Skills != nil { + if sas, ok := pgStores.Skills.(store.SkillAccessStore); ok { skillSearchTool.SetSkillAccessStore(sas) } - if pgSkills, ok := managedStores.Skills.(*pg.PGSkillStore); ok { + if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok { memCfg := cfg.Agents.Defaults.Memory if embProvider := resolveEmbeddingProvider(cfg, memCfg); embProvider != nil { pgSkills.SetEmbeddingProvider(embProvider) skillSearchTool.SetEmbeddingSearcher(pgSkills, embProvider) - slog.Info("managed mode: skill embeddings enabled", "provider", embProvider.Name()) + slog.Info("skill embeddings enabled", "provider", embProvider.Name()) // Backfill embeddings for existing skills go func() { @@ -522,7 +496,7 @@ func runGateway() { } // Cron tool (agent-facing, matching TS cron-tool.ts) - toolsReg.Register(tools.NewCronTool(cronStore)) + toolsReg.Register(tools.NewCronTool(pgStores.Cron)) slog.Info("cron tool registered") // Session tools (list, status, history, send) @@ -544,31 +518,21 @@ func runGateway() { if homeDir != "" { pa.AllowPaths(filepath.Join(homeDir, ".agents", "skills")) } - // Managed mode: also allow the skills store directory (uploaded skill content). - if managedStores != nil && managedStores.Skills != nil { - pa.AllowPaths(managedStores.Skills.Dirs()...) + // Also allow the skills store directory (uploaded skill content). + if pgStores.Skills != nil { + pa.AllowPaths(pgStores.Skills.Dirs()...) } } } - // Memory detection: SQLite (standalone) or PG (managed) — either enables memory. - hasMemory := memMgr != nil - if !hasMemory && managedStores != nil && managedStores.Memory != nil { - hasMemory = true - // PG memory is available but SQLite failed or wasn't created. - // Ensure memory tools are registered so wireManagedExtras can wire PG store to them. - if _, exists := toolsReg.Get("memory_search"); !exists { - toolsReg.Register(tools.NewMemorySearchTool(nil)) - toolsReg.Register(tools.NewMemoryGetTool(nil)) - slog.Info("memory tools registered for managed mode (PG-backed)") - } - } + // Memory tools are PG-backed; always available. + hasMemory := true // Wire SessionStoreAware + BusAware on tools that need them for _, name := range []string{"sessions_list", "session_status", "sessions_history", "sessions_send"} { if t, ok := toolsReg.Get(name); ok { if sa, ok := t.(tools.SessionStoreAware); ok { - sa.SetSessionStore(sessStore) + sa.SetSessionStore(pgStores.Sessions) } if ba, ok := t.(tools.BusAware); ok { ba.SetMessageBus(msgBus) @@ -582,138 +546,76 @@ func runGateway() { } } - // Standalone mode: wire FileAgentStore + interceptors + callbacks. - // Must happen after tool registration (wires interceptors to read_file, write_file, edit). - var fileAgentStore store.AgentStore - var ensureUserFiles agent.EnsureUserFilesFunc - var contextFileLoader agent.ContextFileLoaderFunc - if cfg.Database.Mode != "managed" { - var standaloneCleanup func() - fileAgentStore, ensureUserFiles, contextFileLoader, standaloneCleanup = - wireStandaloneExtras(cfg, toolsReg, dataDir, workspace) - if standaloneCleanup != nil { - defer standaloneCleanup() - } - } - - // Create all agents + // Create all agents — resolved lazily from database by the managed resolver. agentRouter := agent.NewRouter() - - isManaged := managedStores != nil - - // In managed mode, agents are created lazily by the resolver (from DB). - // In standalone mode, create agents eagerly from config. - if !isManaged { - // Always create "default" agent - if err := createAgentLoop("default", cfg, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, contextFiles, skillsLoader, hasMemory, sandboxMgr, fileAgentStore, ensureUserFiles, contextFileLoader); err != nil { - slog.Error("failed to create default agent", "error", err) - os.Exit(1) - } - - // Create additional agents from agents.list - for agentID := range cfg.Agents.List { - if agentID == "default" { - continue - } - if err := createAgentLoop(agentID, cfg, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, contextFiles, skillsLoader, hasMemory, sandboxMgr, fileAgentStore, ensureUserFiles, contextFileLoader); err != nil { - slog.Error("failed to create agent", "agent", agentID, "error", err) - } - } - } else { - slog.Info("managed mode: agents will be resolved lazily from database") - } + slog.Info("agents will be resolved lazily from database") // Create gateway server and wire enforcement - server := gateway.NewServer(cfg, msgBus, agentRouter, sessStore, toolsReg) + server := gateway.NewServer(cfg, msgBus, agentRouter, pgStores.Sessions, toolsReg) server.SetVersion(Version) - if managedStores != nil { - server.SetDB(managedStores.DB) - } + server.SetDB(pgStores.DB) server.SetPolicyEngine(permPE) - server.SetPairingService(pairingStore) + server.SetPairingService(pgStores.Pairing) - // contextFileInterceptor is created inside wireManagedExtras (managed mode only). + // contextFileInterceptor is created inside wireExtras. // Declared here so it can be passed to registerAllMethods → AgentsMethods // for immediate cache invalidation on agents.files.set. var contextFileInterceptor *tools.ContextFileInterceptor var delegateMgr *tools.DelegateManager - // Managed mode: set agent store for tools_invoke context injection + wire extras - if managedStores != nil && managedStores.Agents != nil { - server.SetAgentStore(managedStores.Agents) + // Set agent store for tools_invoke context injection + wire extras + if pgStores.Agents != nil { + server.SetAgentStore(pgStores.Agents) } - if managedStores != nil { - // Dynamic custom tools: load global tools from DB before resolver - var dynamicLoader *tools.DynamicToolLoader - if managedStores.CustomTools != nil { - dynamicLoader = tools.NewDynamicToolLoader(managedStores.CustomTools, workspace) - if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil { - slog.Warn("failed to load global custom tools", "error", err) - } - } - contextFileInterceptor, delegateMgr = wireManagedExtras(managedStores, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader) - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner) - if agentsH != nil { - server.SetAgentsHandler(agentsH) - } - if skillsH != nil { - server.SetSkillsHandler(skillsH) - } - if tracesH != nil { - server.SetTracesHandler(tracesH) - } - if mcpH != nil { - server.SetMCPHandler(mcpH) - } - if customToolsH != nil { - server.SetCustomToolsHandler(customToolsH) - } - if channelInstancesH != nil { - server.SetChannelInstancesHandler(channelInstancesH) - } - if providersH != nil { - server.SetProvidersHandler(providersH) - } - if delegationsH != nil { - server.SetDelegationsHandler(delegationsH) - } - if builtinToolsH != nil { - server.SetBuiltinToolsHandler(builtinToolsH) + // Dynamic custom tools: load global tools from DB before resolver + var dynamicLoader *tools.DynamicToolLoader + if pgStores.CustomTools != nil { + dynamicLoader = tools.NewDynamicToolLoader(pgStores.CustomTools, workspace) + if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil { + slog.Warn("failed to load global custom tools", "error", err) } + } - // Seed + apply builtin tool disables - if managedStores.BuiltinTools != nil { - seedBuiltinTools(context.Background(), managedStores.BuiltinTools) - applyBuiltinToolDisables(context.Background(), managedStores.BuiltinTools, toolsReg) - } + contextFileInterceptor, delegateMgr = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader) + agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner) + if agentsH != nil { + server.SetAgentsHandler(agentsH) + } + if skillsH != nil { + server.SetSkillsHandler(skillsH) + } + if tracesH != nil { + server.SetTracesHandler(tracesH) + } + if mcpH != nil { + server.SetMCPHandler(mcpH) + } + if customToolsH != nil { + server.SetCustomToolsHandler(customToolsH) + } + if channelInstancesH != nil { + server.SetChannelInstancesHandler(channelInstancesH) + } + if providersH != nil { + server.SetProvidersHandler(providersH) + } + if delegationsH != nil { + server.SetDelegationsHandler(delegationsH) + } + if builtinToolsH != nil { + server.SetBuiltinToolsHandler(builtinToolsH) + } + + // Seed + apply builtin tool disables + if pgStores.BuiltinTools != nil { + seedBuiltinTools(context.Background(), pgStores.BuiltinTools) + applyBuiltinToolDisables(context.Background(), pgStores.BuiltinTools, toolsReg) } // Register all RPC methods - var agentStoreForRPC store.AgentStore - if isManaged { - agentStoreForRPC = managedStores.Agents - } - - // SkillStore for RPC methods: PG in managed mode, file wrapper in standalone. - var skillStore store.SkillStore - if managedStores != nil && managedStores.Skills != nil { - skillStore = managedStores.Skills - } else { - skillStore = file.NewFileSkillStore(skillsLoader) - } - - var configSecretsStore store.ConfigSecretsStore - if managedStores != nil { - configSecretsStore = managedStores.ConfigSecrets - } - - var teamStoreForRPC store.TeamStore - if managedStores != nil { - teamStoreForRPC = managedStores.Teams - } - - pairingMethods := registerAllMethods(server, agentRouter, sessStore, cronStore, pairingStore, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, agentStoreForRPC, isManaged, skillStore, configSecretsStore, teamStoreForRPC, contextFileInterceptor) + server.SetLogTee(logTee) + pairingMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee) // Channel manager channelMgr := channels.NewManager(msgBus) @@ -725,11 +627,11 @@ func runGateway() { } } - // Managed mode: load channel instances from DB first. + // Load channel instances from DB. var instanceLoader *channels.InstanceLoader - if managedStores != nil && managedStores.ChannelInstances != nil { - instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore) - instanceLoader.RegisterFactory("telegram", telegram.FactoryWithStores(managedStores.Agents, managedStores.Teams)) + if pgStores.ChannelInstances != nil { + instanceLoader = channels.NewInstanceLoader(pgStores.ChannelInstances, pgStores.Agents, channelMgr, msgBus, pgStores.Pairing) + instanceLoader.RegisterFactory("telegram", telegram.FactoryWithStores(pgStores.Agents, pgStores.Teams)) instanceLoader.RegisterFactory("discord", discord.Factory) instanceLoader.RegisterFactory("feishu", feishu.Factory) instanceLoader.RegisterFactory("zalo_oa", zalo.Factory) @@ -740,10 +642,9 @@ func runGateway() { } } - // Register config-based channels as fallback (standalone mode only). - // In managed mode, channels are loaded from DB via instanceLoader — skip config-based registration. + // Register config-based channels as fallback when no DB instances loaded. if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil { - tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil, nil) + tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil) if err != nil { slog.Error("failed to initialize telegram channel", "error", err) } else { @@ -773,7 +674,7 @@ func runGateway() { } if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" && instanceLoader == nil { - z, err := zalo.New(cfg.Channels.Zalo, msgBus, pairingStore) + z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing) if err != nil { slog.Error("failed to initialize zalo channel", "error", err) } else { @@ -783,7 +684,7 @@ func runGateway() { } if cfg.Channels.ZaloPersonal.Enabled && instanceLoader == nil { - zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pairingStore) + zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pgStores.Pairing) if err != nil { slog.Error("failed to initialize zca channel", "error", err) } else { @@ -793,7 +694,7 @@ func runGateway() { } if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppID != "" && instanceLoader == nil { - f, err := feishu.New(cfg.Channels.Feishu, msgBus, pairingStore) + f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing) if err != nil { slog.Error("failed to initialize feishu channel", "error", err) } else { @@ -815,21 +716,21 @@ func runGateway() { // Register channels RPC methods (after channelMgr is initialized with all channels) methods.NewChannelsMethods(channelMgr).Register(server.Router()) - // Register channel instances WS RPC methods (managed mode only) - if managedStores != nil && managedStores.ChannelInstances != nil { - methods.NewChannelInstancesMethods(managedStores.ChannelInstances, msgBus).Register(server.Router()) - zalomethods.NewQRMethods(managedStores.ChannelInstances, msgBus).Register(server.Router()) - zalomethods.NewContactsMethods(managedStores.ChannelInstances).Register(server.Router()) + // Register channel instances WS RPC methods + if pgStores.ChannelInstances != nil { + methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) + zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) + zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) } - // Register agent links WS RPC methods (managed mode only) - if managedStores != nil && managedStores.AgentLinks != nil && managedStores.Agents != nil { - methods.NewAgentLinksMethods(managedStores.AgentLinks, managedStores.Agents, agentRouter, msgBus).Register(server.Router()) + // Register agent links WS RPC methods + if pgStores.AgentLinks != nil && pgStores.Agents != nil { + methods.NewAgentLinksMethods(pgStores.AgentLinks, pgStores.Agents, agentRouter, msgBus).Register(server.Router()) } - // Register agent teams WS RPC methods (managed mode only) - if managedStores != nil && managedStores.Teams != nil { - methods.NewTeamsMethods(managedStores.Teams, managedStores.Agents, managedStores.AgentLinks, agentRouter, msgBus).Register(server.Router()) + // Register agent teams WS RPC methods + if pgStores.Teams != nil { + methods.NewTeamsMethods(pgStores.Teams, pgStores.Agents, pgStores.AgentLinks, agentRouter, msgBus).Register(server.Router()) } // Cache invalidation: reload channel instances on changes. @@ -870,8 +771,8 @@ func runGateway() { }) // Cascade: when an agent becomes inactive, disable its linked channel instances. - if managedStores != nil && managedStores.ChannelInstances != nil { - ciStore := managedStores.ChannelInstances + if pgStores.ChannelInstances != nil { + ciStore := pgStores.ChannelInstances msgBus.Subscribe(bus.TopicAgentStatusChanged, func(event bus.Event) { if event.Name != bus.EventAgentStatusChanged { return @@ -946,26 +847,20 @@ func runGateway() { defer sched.Stop() // Start cron service with job handler (routes through scheduler's cron lane) - cronStore.SetOnJob(makeCronJobHandler(sched, msgBus, cfg)) - if err := cronStore.Start(); err != nil { + pgStores.Cron.SetOnJob(makeCronJobHandler(sched, msgBus, cfg)) + if err := pgStores.Cron.Start(); err != nil { slog.Warn("cron service failed to start", "error", err) } - // Start heartbeat service (matching TS heartbeat-runner.ts). - heartbeatSvc := setupHeartbeat(cfg, agentRouter, sessStore, msgBus, workspace, managedStores) - if heartbeatSvc != nil { - heartbeatSvc.Start() - } - // Adaptive throttle: reduce per-session concurrency when nearing the summary threshold. // This prevents concurrent runs from racing with summarization. // Uses calibrated token estimation (actual prompt tokens from last LLM call) // and the agent's real context window (cached on session by the Loop). sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) { - history := sessStore.GetHistory(sessionKey) - lastPT, lastMC := sessStore.GetLastPromptTokens(sessionKey) + history := pgStores.Sessions.GetHistory(sessionKey) + lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(sessionKey) tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC) - cw := sessStore.GetContextWindow(sessionKey) + cw := pgStores.Sessions.GetContextWindow(sessionKey) if cw <= 0 { cw = 200000 // fallback for sessions not yet processed } @@ -987,17 +882,14 @@ func runGateway() { }) // Start inbound message consumer (channel → scheduler → agent → channel) - var consumerTeamStore store.TeamStore - if managedStores != nil { - consumerTeamStore = managedStores.Teams - } + consumerTeamStore := pgStores.Teams - // Quota checker (managed mode only): enforces per-user/group request limits. + // Quota checker: enforces per-user/group request limits. // Merge per-group quotas from channel configs into gateway.quota.groups. config.MergeChannelGroupQuotas(cfg) var quotaChecker *channels.QuotaChecker - if managedStores != nil && cfg.Gateway.Quota != nil && cfg.Gateway.Quota.Enabled { - quotaChecker = channels.NewQuotaChecker(managedStores.DB, *cfg.Gateway.Quota) + if cfg.Gateway.Quota != nil && cfg.Gateway.Quota.Enabled { + quotaChecker = channels.NewQuotaChecker(pgStores.DB, *cfg.Gateway.Quota) defer quotaChecker.Stop() slog.Info("channel quota enabled", "default_hour", cfg.Gateway.Quota.Default.Hour, @@ -1006,13 +898,9 @@ func runGateway() { ) } - // Register quota usage RPC (nil-safe — returns {enabled: false} in standalone mode). + // Register quota usage RPC. // Pass DB so summary cards still work when quota is disabled (queries traces directly). - var quotaDB *sql.DB - if managedStores != nil { - quotaDB = managedStores.DB - } - methods.NewQuotaMethods(quotaChecker, quotaDB).Register(server.Router()) + methods.NewQuotaMethods(quotaChecker, pgStores.DB).Register(server.Router()) // Reload quota config on config changes via pub/sub. if quotaChecker != nil { @@ -1051,12 +939,9 @@ func runGateway() { // Broadcast shutdown event server.BroadcastEvent(*protocol.NewEvent(protocol.EventShutdown, nil)) - // Stop channels, cron, and heartbeat + // Stop channels and cron channelMgr.StopAll(context.Background()) - cronStore.Stop() - if heartbeatSvc != nil { - heartbeatSvc.Stop() - } + pgStores.Cron.Stop() // Stop sandbox pruning + release containers if sandboxMgr != nil { @@ -1068,14 +953,10 @@ func runGateway() { cancel() }() - gatewayMode := "standalone" - if cfg.Database.Mode == "managed" { - gatewayMode = "managed" - } slog.Info("goclaw gateway starting", "version", Version, "protocol", protocol.ProtocolVersion, - "mode", gatewayMode, + "mode", "managed", "agents", agentRouter.List(), "tools", toolsReg.Count(), "channels", channelMgr.GetEnabledChannels(), diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 23e133b0..a71de6a3 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -3,189 +3,19 @@ package cmd import ( "context" "log/slog" - "path/filepath" "strings" "time" - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/agent" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/heartbeat" "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" - "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tts" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -// createAgentLoop creates and registers an agent Loop for the given agent ID. -// Works for "default" and any agent in agents.list. -func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, providerReg *providers.Registry, msgBus *bus.MessageBus, sess store.SessionStore, toolsReg *tools.Registry, toolPE *tools.PolicyEngine, contextFiles []bootstrap.ContextFile, skillsLoader *skills.Loader, hasMemory bool, sandboxMgr sandbox.Manager, agentStore store.AgentStore, ensureUserFiles agent.EnsureUserFilesFunc, contextFileLoader agent.ContextFileLoaderFunc) error { - agentCfg := cfg.ResolveAgent(agentID) - - provider, err := providerReg.Get(agentCfg.Provider) - if err != nil { - // Fallback: try any available provider - names := providerReg.List() - if len(names) == 0 { - slog.Warn("no providers configured, agent will fail on first LLM call", "agent", agentID) - return nil - } - provider, _ = providerReg.Get(names[0]) - slog.Warn("configured provider not found, using fallback", "agent", agentID, "wanted", agentCfg.Provider, "using", names[0]) - } - - if provider == nil { - slog.Warn("no provider available for agent", "agent", agentID) - return nil - } - - workspace := config.ExpandHome(agentCfg.Workspace) - if !filepath.IsAbs(workspace) { - workspace, _ = filepath.Abs(workspace) - } - - // Resolve sandbox info for system prompt - sandboxEnabled := sandboxMgr != nil - sandboxContainerDir := "" - sandboxWorkspaceAccess := "" - if sandboxEnabled { - sbCfg := agentCfg.Sandbox - if sbCfg == nil { - sbCfg = cfg.Agents.Defaults.Sandbox - } - if sbCfg != nil { - resolved := sbCfg.ToSandboxConfig() - sandboxContainerDir = resolved.ContainerWorkdir() - sandboxWorkspaceAccess = string(resolved.WorkspaceAccess) - } - } - - // Per-agent skill allowlist. - // AgentSpec.Skills: nil = all skills, [] = none, ["x","y"] = only those. - var skillAllowList []string - var agentToolPolicy *config.ToolPolicySpec - if spec, ok := cfg.Agents.List[agentID]; ok { - skillAllowList = spec.Skills - agentToolPolicy = spec.Tools - } - - // Resolve AgentUUID and AgentType from store (standalone mode with FileAgentStore) - var agentUUID uuid.UUID - var agentType string - if agentStore != nil { - if ad, err := agentStore.GetByKey(context.Background(), agentID); err == nil { - agentUUID = ad.ID - agentType = ad.AgentType - } - } - - loop := agent.NewLoop(agent.LoopConfig{ - ID: agentID, - AgentUUID: agentUUID, - AgentType: agentType, - Provider: provider, - Model: agentCfg.Model, - ContextWindow: agentCfg.ContextWindow, - MaxIterations: agentCfg.MaxToolIterations, - MaxToolCalls: agentCfg.MaxToolCalls, - Workspace: workspace, - Bus: msgBus, - Sessions: sess, - Tools: toolsReg, - ToolPolicy: toolPE, - AgentToolPolicy: agentToolPolicy, - OwnerIDs: cfg.Gateway.OwnerIDs, - SkillsLoader: skillsLoader, - SkillAllowList: skillAllowList, - HasMemory: hasMemory, - ContextFiles: contextFiles, - EnsureUserFiles: ensureUserFiles, - ContextFileLoader: contextFileLoader, - BootstrapCleanup: buildBootstrapCleanup(agentStore), - CompactionCfg: cfg.Agents.Defaults.Compaction, - ContextPruningCfg: cfg.Agents.Defaults.ContextPruning, - SandboxEnabled: sandboxEnabled, - SandboxContainerDir: sandboxContainerDir, - SandboxWorkspaceAccess: sandboxWorkspaceAccess, - InjectionAction: cfg.Gateway.InjectionAction, - MaxMessageChars: cfg.Gateway.MaxMessageChars, - OnEvent: func(event agent.AgentEvent) { - msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgent, - Payload: event, - }) - }, - }) - - router.Register(loop) - slog.Info("created agent", "agent", agentID, "model", agentCfg.Model, "provider", agentCfg.Provider) - return nil -} - -func setupMemory(workspace string, appCfg *config.Config) *memory.Manager { - memCfg := appCfg.Agents.Defaults.Memory - - // Check if explicitly disabled - if memCfg != nil && memCfg.Enabled != nil && !*memCfg.Enabled { - slog.Info("memory system disabled by config") - return nil - } - - mgrCfg := memory.DefaultManagerConfig(workspace) - - // Apply config overrides - if memCfg != nil { - if memCfg.MaxResults > 0 { - mgrCfg.MaxResults = memCfg.MaxResults - } - if memCfg.MaxChunkLen > 0 { - mgrCfg.MaxChunkLen = memCfg.MaxChunkLen - } - if memCfg.VectorWeight > 0 { - mgrCfg.VectorWeight = memCfg.VectorWeight - } - if memCfg.TextWeight > 0 { - mgrCfg.TextWeight = memCfg.TextWeight - } - } - - mgr, err := memory.NewManager(mgrCfg) - if err != nil { - slog.Warn("memory system unavailable", "error", err) - return nil - } - - // Auto-wire embedding provider (matching TS priority: openai → openrouter → gemini) - provider := resolveEmbeddingProvider(appCfg, memCfg) - if provider != nil { - mgr.SetEmbeddingProvider(provider) - slog.Info("memory embeddings enabled", "provider", provider.Name(), "model", provider.Model()) - } else { - slog.Info("memory embeddings disabled (no API key), FTS-only mode") - } - - // Index existing memory files on startup - ctx := context.Background() - if err := mgr.IndexAll(ctx); err != nil { - slog.Warn("memory initial indexing failed", "error", err) - } - - // Start file watcher for auto re-indexing on changes - // (matching TS chokidar watcher with 1500ms debounce) - if err := mgr.StartWatcher(ctx); err != nil { - slog.Warn("memory file watcher unavailable", "error", err) - } - - return mgr -} - // resolveEmbeddingProvider auto-selects an embedding provider based on config and available API keys. // Matching TS embedding provider auto-selection order. func resolveEmbeddingProvider(cfg *config.Config, memCfg *config.MemoryConfig) memory.EmbeddingProvider { @@ -367,84 +197,14 @@ func setupTTS(cfg *config.Config) *tts.Manager { return mgr } -// setupHeartbeat creates and configures the heartbeat service from config. -// Returns nil if heartbeats are disabled (every="0m" or no config). -// Matching TS startHeartbeatRunner(). -func setupHeartbeat(cfg *config.Config, router *agent.Router, sess store.SessionStore, msgBus *bus.MessageBus, workspace string, managedStores *store.Stores) *heartbeat.Service { - hbCfg := cfg.Agents.Defaults.Heartbeat - - // Determine interval - interval := heartbeat.DefaultInterval() - if hbCfg != nil && hbCfg.Every != "" { - d, err := parseDuration(hbCfg.Every) - if err != nil { - slog.Warn("heartbeat: invalid 'every' value, using default", "value", hbCfg.Every, "error", err) - } else { - interval = d - } - } - - // Disabled - if interval <= 0 { - slog.Info("heartbeat disabled (every=0)") - return nil - } - - agentID := resolveDefaultAgentManaged(cfg, managedStores) - - svcCfg := heartbeat.Config{ - AgentID: agentID, - Interval: interval, - Workspace: workspace, - } - - if hbCfg != nil { - svcCfg.ActiveHours = hbCfg.ActiveHours - svcCfg.Model = hbCfg.Model - svcCfg.Target = hbCfg.Target - svcCfg.To = hbCfg.To - svcCfg.Prompt = hbCfg.Prompt - svcCfg.AckMaxChars = hbCfg.AckMaxChars - if hbCfg.Session != "" { - svcCfg.SessionKey = hbCfg.Session - } - } - - // Build agent runner callback - runner := func(ctx context.Context, aID, sessionKey, message, runID string) (string, error) { - loop, err := router.Get(aID) - if err != nil { - return "", err - } - result, err := loop.Run(ctx, agent.RunRequest{ - SessionKey: sessionKey, - Message: message, - Channel: "heartbeat", - RunID: runID, - Stream: false, - }) - if err != nil { - return "", err - } - return result.Content, nil - } - - // Build last-used resolver - lastUsed := func(aID string) (string, string) { - return sess.LastUsedChannel(aID) - } - - return heartbeat.NewService(svcCfg, runner, msgBus, lastUsed) -} - // parseDuration parses a duration string like "30m", "1h", "0m". func parseDuration(s string) (time.Duration, error) { return time.ParseDuration(s) } // resolveDefaultAgentManaged resolves the default agent ID, falling back to the -// managed-mode DB when config returns the generic "default" (which doesn't exist -// as a real agent in managed mode — agents live in the DB, not config). +// DB when config returns the generic "default" (which doesn't exist +// as a real agent — agents live in the DB, not config). func resolveDefaultAgentManaged(cfg *config.Config, managedStores *store.Stores) string { agentID := cfg.ResolveDefaultAgentID() diff --git a/cmd/gateway_callbacks.go b/cmd/gateway_callbacks.go index 48587c96..f97a9966 100644 --- a/cmd/gateway_callbacks.go +++ b/cmd/gateway_callbacks.go @@ -16,7 +16,6 @@ import ( ) // buildEnsureUserFiles creates the per-user file seeding callback. -// Used by both managed and standalone modes — no mode-specific logic. // Seeds per-user context files on first chat (new user profile). func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.EnsureUserFilesFunc { return func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (string, error) { @@ -64,7 +63,6 @@ func buildBootstrapCleanup(as store.AgentStore) agent.BootstrapCleanupFunc { } // buildContextFileLoader creates the per-request context file loader callback. -// Used by both managed and standalone modes — no mode-specific logic. // Delegates to the ContextFileInterceptor for type-aware routing. func buildContextFileLoader(intc *tools.ContextFileInterceptor) agent.ContextFileLoaderFunc { return func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile { diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 78db569e..6f67a1fd 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -70,7 +70,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind) } - // Check handoff routing override (managed mode only) + // Check handoff routing override if teamStore != nil && msg.AgentID == "" { if route, _ := teamStore.GetHandoffRoute(ctx, msg.Channel, msg.ChatID); route != nil { agentID = route.ToAgentKey @@ -126,7 +126,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID) } - // --- Quota check (managed mode only) --- + // --- Quota check --- if quotaChecker != nil { qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName()) if !qResult.Allowed { diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 6f01e5d1..f73f10d1 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -21,13 +21,13 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -// wireManagedExtras wires managed-mode components that require PG stores: +// wireExtras wires components that require PG stores: // agent resolver (lazy-creates Loops from DB), virtual FS interceptors, memory tools, // and cache invalidation event subscribers. // PG store creation and tracing are handled in gateway.go before this is called. // Returns the ContextFileInterceptor so callers can pass it to AgentsMethods // for immediate cache invalidation on agents.files.set. -func wireManagedExtras( +func wireExtras( stores *store.Stores, agentRouter *agent.Router, providerReg *providers.Registry, @@ -165,7 +165,7 @@ func wireManagedExtras( } } - // Wire group writer cache for permission checks (managed mode only) + // Wire group writer cache for permission checks if groupWriterCache != nil { for _, toolName := range []string{"read_file", "write_file", "edit", "cron"} { if t, ok := toolsReg.Get(toolName); ok { @@ -397,7 +397,7 @@ func wireManagedExtras( if embProvider := resolveEmbeddingProvider(appCfg, memCfg); embProvider != nil { agentStore.SetEmbeddingProvider(embProvider) delegateEmbProvider = embProvider - slog.Info("managed mode: agent embeddings enabled") + slog.Info("agent embeddings enabled") // Backfill embeddings for existing agents with frontmatter go func() { @@ -411,7 +411,7 @@ func wireManagedExtras( } } toolsReg.Register(tools.NewDelegateSearchTool(stores.AgentLinks, delegateEmbProvider)) - slog.Info("managed mode: delegate + delegate_search tools registered") + slog.Info("delegate + delegate_search tools registered") } // Register team tools (team_tasks + team_message) if team store is available. @@ -434,7 +434,7 @@ func wireManagedExtras( } teamMgr.InvalidateTeam() }) - slog.Info("managed mode: team tools registered") + slog.Info("team tools registered") } // User workspace cache: invalidate per-user workspace path on profile changes @@ -469,12 +469,12 @@ func wireManagedExtras( }) } - slog.Info("managed mode: resolver + interceptors + cache subscribers wired") + slog.Info("resolver + interceptors + cache subscribers wired") return contextFileInterceptor, delegateMgr } -// wireManagedHTTP creates managed-mode HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools). -func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) { +// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools). +func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 8ec22522..2bf9365f 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -12,14 +12,14 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, isManaged bool, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor) *methods.PairingMethods { +func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee) *methods.PairingMethods { router := server.Router() // Phase 1: Core methods - methods.NewChatMethods(agents, sessStore, isManaged, server.RateLimiter()).Register(router) - methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, isManaged, contextFileInterceptor).Register(router) + methods.NewChatMethods(agents, sessStore, server.RateLimiter()).Register(router) + methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor).Register(router) methods.NewSessionsMethods(sessStore).Register(router) - methods.NewConfigMethods(cfg, cfgPath, isManaged, configSecretsStore, msgBus).Register(router) + methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router) // Phase 2: Skills (uses SkillStore interface — PG or File) methods.NewSkillsMethods(skillStore).Register(router) @@ -41,7 +41,10 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore // Phase 2: Send (outbound message routing) methods.NewSendMethods(msgBus).Register(router) - // Phase 4: Delegation history (managed mode only) + // Phase 3: Live log tailing + methods.NewLogsMethods(logTee).Register(router) + + // Phase 4: Delegation history if teamStore != nil { methods.NewDelegationsMethods(teamStore).Register(router) } diff --git a/cmd/gateway_standalone.go b/cmd/gateway_standalone.go deleted file mode 100644 index df272832..00000000 --- a/cmd/gateway_standalone.go +++ /dev/null @@ -1,110 +0,0 @@ -package cmd - -import ( - "context" - "log/slog" - "path/filepath" - - "github.com/nextlevelbuilder/goclaw/internal/agent" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" - "github.com/nextlevelbuilder/goclaw/internal/tools" -) - -// wireStandaloneExtras sets up standalone-mode components that mirror managed mode: -// FileAgentStore (filesystem + SQLite), ContextFileInterceptor, per-user seeding callbacks. -// Returns the agent store, interceptor callbacks, and a cleanup function. -func wireStandaloneExtras( - cfg *config.Config, - toolsReg *tools.Registry, - dataDir string, - workspace string, -) (agentStore store.AgentStore, ensureUserFiles agent.EnsureUserFilesFunc, contextFileLoader agent.ContextFileLoaderFunc, cleanup func()) { - // Build agent entries from config - var entries []file.AgentEntry - - // Default agent - defaultCfg := cfg.ResolveAgent("default") - defaultWS := config.ExpandHome(defaultCfg.Workspace) - if !filepath.IsAbs(defaultWS) { - defaultWS, _ = filepath.Abs(defaultWS) - } - entries = append(entries, file.AgentEntry{ - Key: "default", - AgentType: defaultCfg.AgentType, - Workspace: defaultWS, - }) - - // Additional agents from agents.list - for agentID := range cfg.Agents.List { - if agentID == "default" { - continue - } - agentCfg := cfg.ResolveAgent(agentID) - ws := config.ExpandHome(agentCfg.Workspace) - if !filepath.IsAbs(ws) { - ws, _ = filepath.Abs(ws) - } - entries = append(entries, file.AgentEntry{ - Key: agentID, - AgentType: agentCfg.AgentType, - Workspace: ws, - }) - } - - // Create FileAgentStore (SQLite at {dataDir}/agents.db) - dbPath := filepath.Join(dataDir, "agents.db") - fileStore, err := file.NewFileAgentStore(dbPath, entries) - if err != nil { - slog.Error("failed to create file agent store", "error", err) - return nil, nil, nil, nil - } - - // Seed predefined agents' context files to disk - bgCtx := context.Background() - for _, e := range entries { - if e.AgentType == store.AgentTypePredefined { - id := file.AgentUUID(e.Key) - if _, seedErr := bootstrap.SeedToStore(bgCtx, fileStore, id, store.AgentTypePredefined); seedErr != nil { - slog.Warn("failed to seed predefined agent context files", "agent", e.Key, "error", seedErr) - } - } - } - - // Create ContextFileInterceptor — same as managed mode, different backing store - contextFileInterceptor := tools.NewContextFileInterceptor(fileStore, workspace) - - // Build callbacks using shared builders - ensureUserFiles = buildEnsureUserFiles(fileStore, nil) - contextFileLoader = buildContextFileLoader(contextFileInterceptor) - - // Wire interceptors to filesystem tools (read_file, write_file, edit) - for _, toolName := range []string{"read_file", "write_file", "edit"} { - if t, ok := toolsReg.Get(toolName); ok { - if ia, ok := t.(tools.InterceptorAware); ok { - ia.SetContextFileInterceptor(contextFileInterceptor) - } - } - } - - // Deny access to hidden directories (e.g. .goclaw) from filesystem tools - hiddenDirs := []string{".goclaw"} - for _, toolName := range []string{"read_file", "write_file", "list_files", "edit"} { - if t, ok := toolsReg.Get(toolName); ok { - if pd, ok := t.(tools.PathDenyable); ok { - pd.DenyPaths(hiddenDirs...) - } - } - } - - cleanup = func() { - fileStore.Close() - } - - slog.Info("standalone mode: agent store + interceptors wired", - "agents", len(entries), "db", dbPath) - - return fileStore, ensureUserFiles, contextFileLoader, cleanup -} diff --git a/cmd/onboard.go b/cmd/onboard.go index 58a080cd..2061fdcc 100644 --- a/cmd/onboard.go +++ b/cmd/onboard.go @@ -116,7 +116,6 @@ func runOnboard() { ttsGroupID string ttsAutoMode = "off" - dbMode = "standalone" postgresDSN string traceVerbose bool ) @@ -165,10 +164,7 @@ func runOnboard() { } ttsGroupID = cfg.Tts.MiniMax.GroupID } - if cfg.Database.Mode == "managed" { - dbMode = "managed" - postgresDSN = cfg.Database.PostgresDSN - } + postgresDSN = cfg.Database.PostgresDSN // Pre-fill API key from env or config apiKey = resolveExistingAPIKey(cfg, providerChoice) @@ -347,24 +343,13 @@ func runOnboard() { return } - // ── Step 3: Database mode ── - dbMode, err = promptSelect("Step 3 · Database Mode", []SelectOption[string]{ - {"Standalone (file-based, no database required)", "standalone"}, - {"Managed (Postgres — multi-user, tracing, agent API)", "managed"}, - }, 0) + // ── Step 3: Database ── + postgresDSN, err = promptString("Step 3 · Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", postgresDSN) if err != nil { fmt.Println("Cancelled.") return } - if dbMode == "managed" { - postgresDSN, err = promptString("Postgres DSN", "Connection string for your Postgres database", postgresDSN) - if err != nil { - fmt.Println("Cancelled.") - return - } - } - // --- Post-form validation --- var errors []string @@ -397,8 +382,8 @@ func runOnboard() { errors = append(errors, "Feishu App ID and App Secret are required") } - if dbMode == "managed" && postgresDSN == "" { - errors = append(errors, "Postgres DSN is required for managed mode") + if postgresDSN == "" { + errors = append(errors, "Postgres DSN is required (set GOCLAW_POSTGRES_DSN or enter above)") } if len(errors) > 0 { @@ -512,73 +497,54 @@ func runOnboard() { } // Database - if dbMode == "managed" { - cfg.Database.Mode = "managed" - cfg.Database.PostgresDSN = postgresDSN + cfg.Database.PostgresDSN = postgresDSN - // Auto-generate encryption key for API keys in DB (if not already set). - if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" { - encKey := onboardGenerateToken(32) - os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey) - fmt.Printf(" Generated encryption key for API keys (AES-256-GCM)\n") + // Auto-generate encryption key for API keys in DB (if not already set). + if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" { + encKey := onboardGenerateToken(32) + os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey) + fmt.Printf(" Generated encryption key for API keys (AES-256-GCM)\n") + } else { + fmt.Println(" Using existing GOCLAW_ENCRYPTION_KEY from environment") + } + + fmt.Print(" Testing Postgres connection... ") + if err := testPostgresConnection(postgresDSN); err != nil { + fmt.Println("FAILED") + fmt.Printf(" Error: %v\n", err) + fmt.Println(" Please check your DSN and try again: ./goclaw onboard") + return + } + fmt.Println("OK") + + runMigrate, err := promptConfirm("Run database migration now?", true) + if err != nil { + fmt.Println("Cancelled.") + return + } + if runMigrate { + fmt.Println(" Running migration...") + m, err := newMigrator(postgresDSN) + if err != nil { + fmt.Printf(" Migration error: %v\n", err) + fmt.Println(" You can run it manually later: ./goclaw migrate up") } else { - fmt.Println(" Using existing GOCLAW_ENCRYPTION_KEY from environment") - } - - fmt.Print(" Testing Postgres connection... ") - if err := testPostgresConnection(postgresDSN); err != nil { - fmt.Println("FAILED") - fmt.Printf(" Error: %v\n", err) - fmt.Println() - - fallbackStandalone, err := promptConfirm("Connection failed. Fall back to standalone mode?", false) - if err != nil { - fmt.Println("Cancelled.") - return - } - if fallbackStandalone { - cfg.Database.Mode = "" - cfg.Database.PostgresDSN = "" - fmt.Println(" Switched to standalone mode.") + if err := m.Up(); err != nil && err.Error() != "no change" { + fmt.Printf(" Migration error: %v\n", err) + fmt.Println(" You can run it manually later: ./goclaw migrate up") } else { - fmt.Println(" Please check your DSN and try again: ./goclaw onboard") - return + v, _, _ := m.Version() + fmt.Printf(" Migration complete (version: %d)\n", v) } - } else { - fmt.Println("OK") + m.Close() } - if cfg.Database.Mode == "managed" { - runMigrate, err := promptConfirm("Run database migration now?", true) - if err != nil { - fmt.Println("Cancelled.") - return - } - if runMigrate { - fmt.Println(" Running migration...") - m, err := newMigrator(postgresDSN) - if err != nil { - fmt.Printf(" Migration error: %v\n", err) - fmt.Println(" You can run it manually later: ./goclaw migrate up") - } else { - if err := m.Up(); err != nil && err.Error() != "no change" { - fmt.Printf(" Migration error: %v\n", err) - fmt.Println(" You can run it manually later: ./goclaw migrate up") - } else { - v, _, _ := m.Version() - fmt.Printf(" Migration complete (version: %d)\n", v) - } - m.Close() - } - - fmt.Println(" Seeding default agent and provider...") - if err := seedManagedData(postgresDSN, cfg); err != nil { - fmt.Printf(" Seed warning: %v\n", err) - fmt.Println(" You can seed manually via the API after starting the gateway.") - } else { - fmt.Println(" Default agent and provider seeded.") - } - } + fmt.Println(" Seeding default agent and provider...") + if err := seedManagedData(postgresDSN, cfg); err != nil { + fmt.Printf(" Seed warning: %v\n", err) + fmt.Println(" You can seed manually via the API after starting the gateway.") + } else { + fmt.Println(" Default agent and provider seeded.") } } @@ -638,11 +604,7 @@ func runOnboard() { fmt.Printf(" Gateway: ws://%s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf(" Token: %s\n", cfg.Gateway.Token) fmt.Printf(" Workspace: %s\n", cfg.Agents.Defaults.Workspace) - if cfg.Database.Mode == "managed" { - fmt.Println(" Database: managed (Postgres)") - } else { - fmt.Println(" Database: standalone (file-based)") - } + fmt.Println(" Database: PostgreSQL") if cfg.Channels.Telegram.Enabled { fmt.Println(" Telegram: enabled") } diff --git a/cmd/onboard_auto.go b/cmd/onboard_auto.go index f3e0fff7..3a046074 100644 --- a/cmd/onboard_auto.go +++ b/cmd/onboard_auto.go @@ -88,12 +88,8 @@ func runAutoOnboard(cfgPath string) bool { slog.Info("auto-onboard: generated gateway token") } - // 3. Managed mode: Postgres setup - // Auto-detect: if GOCLAW_POSTGRES_DSN is set, assume managed mode even without GOCLAW_MODE - if cfg.Database.PostgresDSN != "" && cfg.Database.Mode == "" { - cfg.Database.Mode = "managed" - } - if cfg.Database.Mode == "managed" && cfg.Database.PostgresDSN != "" { + // 3. Database: Postgres setup + if cfg.Database.PostgresDSN != "" { fmt.Print(" Testing Postgres connection...") // Retry loop: database container may still be starting @@ -219,38 +215,12 @@ func detectProvider(cfg *config.Config) (string, string) { // saveCleanConfig saves a minimal config.json without noise (empty providers, // disabled channels, stripped secrets). Only includes sections relevant to // the active configuration so the file serves as clean documentation. -// In managed mode, channels are stored in the DB (channel_instances table), +// Channels are stored in the DB (channel_instances table), // so they are omitted from config.json to avoid dual-connection. func saveCleanConfig(cfgPath string, cfg *config.Config) error { - isManaged := cfg.Database.Mode == "managed" - - // Build channels map — only include enabled channels. - // In managed mode, skip channels entirely (they're DB instances now). + // Channels are stored in the DB (channel_instances table), + // so they are omitted from config.json to avoid dual-connection. channels := make(map[string]interface{}) - if !isManaged { - if cfg.Channels.Telegram.Enabled { - channels["telegram"] = map[string]interface{}{ - "enabled": true, - "reaction_level": nonEmpty(cfg.Channels.Telegram.ReactionLevel, "full"), - "history_limit": nonZero(cfg.Channels.Telegram.HistoryLimit, 50), - } - } - if cfg.Channels.Discord.Enabled { - channels["discord"] = map[string]interface{}{"enabled": true} - } - if cfg.Channels.Slack.Enabled { - channels["slack"] = map[string]interface{}{"enabled": true} - } - if cfg.Channels.Feishu.Enabled { - channels["feishu"] = map[string]interface{}{"enabled": true} - } - if cfg.Channels.Zalo.Enabled { - channels["zalo"] = map[string]interface{}{"enabled": true} - } - if cfg.Channels.WhatsApp.Enabled { - channels["whatsapp"] = map[string]interface{}{"enabled": true} - } - } // Build tools section. tools := map[string]interface{}{ @@ -322,11 +292,6 @@ func saveCleanConfig(cfgPath string, cfg *config.Config) error { root["channels"] = channels } - if cfg.Database.Mode != "" { - root["database"] = map[string]interface{}{ - "mode": cfg.Database.Mode, - } - } data, err := json.MarshalIndent(root, "", " ") if err != nil { diff --git a/cmd/onboard_helpers.go b/cmd/onboard_helpers.go index c6609ea6..eb045aa9 100644 --- a/cmd/onboard_helpers.go +++ b/cmd/onboard_helpers.go @@ -59,12 +59,12 @@ func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnv lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_SECRET=%s", cfg.Channels.Feishu.AppSecret)) } - // Database (managed mode) + // Database if cfg.Database.PostgresDSN != "" { lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", cfg.Database.PostgresDSN)) } - // Encryption key for API keys in DB (managed mode) + // Encryption key for API keys in DB if encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY"); encKey != "" { lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encKey)) } diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index e34cf74a..e325402b 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -36,7 +36,6 @@ func testPostgresConnection(dsn string) error { func seedManagedData(dsn string, cfg *config.Config) error { storeCfg := store.StoreConfig{ PostgresDSN: dsn, - Mode: "managed", EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), } stores, err := pg.NewPGStores(storeCfg) diff --git a/cmd/sessions_cmd.go b/cmd/sessions_cmd.go index 45ae6fc0..cf376433 100644 --- a/cmd/sessions_cmd.go +++ b/cmd/sessions_cmd.go @@ -9,10 +9,7 @@ import ( "github.com/spf13/cobra" - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/sessions" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/store/file" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -34,13 +31,7 @@ func sessionsListCmd() *cobra.Command { Use: "list", Short: "List all sessions", Run: func(cmd *cobra.Command, args []string) { - if isManagedMode() { - sessionsListRPC(agentFilter, jsonOutput) - return - } - mgr := loadSessionsStore() - infos := mgr.List(agentFilter) - printSessionInfos(infos, jsonOutput) + sessionsListRPC(agentFilter, jsonOutput) }, } cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") @@ -54,16 +45,7 @@ func sessionsDeleteCmd() *cobra.Command { Short: "Delete a session", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - if isManagedMode() { - sessionsDeleteRPC(args[0]) - return - } - mgr := loadSessionsStore() - if err := mgr.Delete(args[0]); err != nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - os.Exit(1) - } - fmt.Printf("Deleted session: %s\n", args[0]) + sessionsDeleteRPC(args[0]) }, } } @@ -74,21 +56,15 @@ func sessionsResetCmd() *cobra.Command { Short: "Clear session history (keep session)", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - if isManagedMode() { - sessionsResetRPC(args[0]) - return - } - mgr := loadSessionsStore() - mgr.Reset(args[0]) - fmt.Printf("Reset session: %s\n", args[0]) + sessionsResetRPC(args[0]) }, } } -// --- RPC implementations (managed mode) --- +// --- RPC implementations --- func sessionsListRPC(agentFilter string, jsonOutput bool) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]string{"agentId": agentFilter}) resp, err := gatewayRPC(protocol.MethodSessionsList, params) @@ -115,7 +91,7 @@ func sessionsListRPC(agentFilter string, jsonOutput bool) { } func sessionsDeleteRPC(key string) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]string{"key": key}) resp, err := gatewayRPC(protocol.MethodSessionsDelete, params) @@ -131,7 +107,7 @@ func sessionsDeleteRPC(key string) { } func sessionsResetRPC(key string) { - requireGatewayForManaged() + requireGateway() params, _ := json.Marshal(map[string]string{"key": key}) resp, err := gatewayRPC(protocol.MethodSessionsReset, params) @@ -173,15 +149,6 @@ func printSessionInfos(infos []store.SessionInfo, jsonOutput bool) { tw.Flush() } -// --- File-based helpers (standalone mode) --- - -func loadSessionsStore() store.SessionStore { - cfgPath := resolveConfigPath() - cfg, _ := config.Load(cfgPath) - storage := config.ExpandHome(cfg.Sessions.Storage) - return file.NewFileSessionStore(sessions.NewManager(storage)) -} - func truncateStr(s string, max int) string { if len(s) <= max { return s diff --git a/cmd/upgrade.go b/cmd/upgrade.go index d4ca70e3..972623fc 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -47,9 +47,8 @@ func runUpgradeStatus() error { fmt.Printf(" App version: %s (protocol %d)\n", Version, protocol.ProtocolVersion) - if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" { - fmt.Println(" Mode: standalone (no database)") - fmt.Println(" Status: N/A (no schema migrations needed)") + if cfg.Database.PostgresDSN == "" { + fmt.Println(" Database: NOT CONFIGURED (set GOCLAW_POSTGRES_DSN)") return nil } @@ -107,8 +106,8 @@ func runUpgrade(dryRun bool) error { return fmt.Errorf("load config: %w", err) } - if cfg.Database.Mode != "managed" || cfg.Database.PostgresDSN == "" { - fmt.Println("Standalone mode — no database migrations needed.") + if cfg.Database.PostgresDSN == "" { + fmt.Println("Database not configured. Set GOCLAW_POSTGRES_DSN to enable migrations.") return nil } diff --git a/docker-compose.browser.yml b/docker-compose.browser.yml index 0a22c2f8..9191e3bb 100644 --- a/docker-compose.browser.yml +++ b/docker-compose.browser.yml @@ -1,7 +1,7 @@ # Browser sidecar overlay — adds headless Chrome for the browser automation tool. # -# Usage (with managed mode): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.browser.yml up -d --build +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.browser.yml up -d --build # # The Chrome sidecar exposes CDP (Chrome DevTools Protocol) on port 9222. # GoClaw connects to it automatically via GOCLAW_BROWSER_REMOTE_URL. diff --git a/docker-compose.otel.yml b/docker-compose.otel.yml index 47e9d4cb..20e41155 100644 --- a/docker-compose.otel.yml +++ b/docker-compose.otel.yml @@ -1,7 +1,7 @@ # OTel overlay — rebuilds with OTel support + adds Jaeger for trace visualization. # -# Usage (with managed mode): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.otel.yml up +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up # # Jaeger UI: http://localhost:16686 diff --git a/docker-compose.managed.yml b/docker-compose.postgres.yml similarity index 79% rename from docker-compose.managed.yml rename to docker-compose.postgres.yml index 874a58c9..ed6cd82d 100644 --- a/docker-compose.managed.yml +++ b/docker-compose.postgres.yml @@ -1,7 +1,7 @@ -# Managed overlay — PostgreSQL (pgvector) for multi-tenant storage. +# PostgreSQL overlay — pgvector for multi-tenant storage. # -# Recommended usage (with Web Dashboard): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.selfservice.yml up -d --build +# Usage (with Web Dashboard): +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build # # Required env vars (set in .env or shell): # GOCLAW_OPENROUTER_API_KEY (or another provider key) @@ -30,7 +30,6 @@ services: postgres: condition: service_healthy environment: - - GOCLAW_MODE=managed - GOCLAW_POSTGRES_DSN=postgres://${POSTGRES_USER:-goclaw}:${POSTGRES_PASSWORD:-goclaw}@postgres:5432/${POSTGRES_DB:-goclaw}?sslmode=disable volumes: - goclaw-skills:/app/skills diff --git a/docker-compose.sandbox.yml b/docker-compose.sandbox.yml index 42b765a2..eeaa014d 100644 --- a/docker-compose.sandbox.yml +++ b/docker-compose.sandbox.yml @@ -4,8 +4,8 @@ # 1. Build the sandbox image: docker build -t goclaw-sandbox:bookworm-slim -f Dockerfile.sandbox . # 2. Ensure Docker socket is accessible # -# Usage (with managed mode): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.sandbox.yml up +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.sandbox.yml up # # SECURITY NOTE: Mounting Docker socket gives the container control over host Docker. # Only use in trusted environments where agent code execution isolation is required. diff --git a/docker-compose.selfservice.yml b/docker-compose.selfservice.yml index e0466ad5..9bc86771 100644 --- a/docker-compose.selfservice.yml +++ b/docker-compose.selfservice.yml @@ -1,7 +1,7 @@ # Self-service overlay — adds web dashboard UI (nginx + React SPA). # # Recommended usage: -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.selfservice.yml up -d --build +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build # # Dashboard: http://localhost:3000 diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml deleted file mode 100644 index 7cb9afa2..00000000 --- a/docker-compose.standalone.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Standalone overlay — file-based storage with persistent volumes. -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up - -services: - goclaw: - volumes: - - ./config.json:/app/config.json:ro - - goclaw-workspace:/app/workspace - - goclaw-data:/app/data - - goclaw-sessions:/app/sessions - - goclaw-skills:/app/skills - -volumes: - goclaw-workspace: - goclaw-data: - goclaw-sessions: - goclaw-skills: diff --git a/docker-compose.tailscale.yml b/docker-compose.tailscale.yml index 7bd4043e..b1e9fa82 100644 --- a/docker-compose.tailscale.yml +++ b/docker-compose.tailscale.yml @@ -1,7 +1,7 @@ # Tailscale overlay — rebuilds with tsnet support for secure remote access. # -# Usage (with managed mode): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.tailscale.yml up +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.tailscale.yml up # # Required: # GOCLAW_TSNET_AUTH_KEY — Tailscale auth key (from https://login.tailscale.com/admin/settings/keys) diff --git a/docker-compose.upgrade.yml b/docker-compose.upgrade.yml index a5069e52..1f058848 100644 --- a/docker-compose.upgrade.yml +++ b/docker-compose.upgrade.yml @@ -2,13 +2,13 @@ # # Usage: # # Preview changes (dry-run): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --dry-run +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.upgrade.yml run --rm upgrade --dry-run # # # Apply upgrade: -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.upgrade.yml run --rm upgrade # # # Check status: -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.upgrade.yml run --rm upgrade --status +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.upgrade.yml run --rm upgrade --status # # The upgrade service runs goclaw upgrade and exits. Use --rm to auto-remove the container. @@ -23,7 +23,6 @@ services: - path: .env required: false environment: - - GOCLAW_MODE=managed - GOCLAW_POSTGRES_DSN=postgres://${POSTGRES_USER:-goclaw}:${POSTGRES_PASSWORD:-goclaw}@postgres:5432/${POSTGRES_DB:-goclaw}?sslmode=disable - GOCLAW_CONFIG=/app/data/config.json - GOCLAW_MIGRATIONS_DIR=/app/migrations diff --git a/docker-compose.yml b/docker-compose.yml index 7d4770ef..b33e9f92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,11 @@ # Base docker-compose — shared service definition. -# Combine with an overlay for your deployment mode: +# Combine with overlays for your deployment: # -# Recommended (Managed + Web Dashboard): -# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.selfservice.yml up -d --build +# Recommended (+ Web Dashboard): +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build # -# Standalone: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --build -# Managed only: docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d --build -# Managed+OTel: docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.otel.yml up -d --build +# Without dashboard: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build +# With OTel tracing: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up -d --build services: goclaw: @@ -21,43 +20,16 @@ services: - path: .env required: false environment: - # Core - GOCLAW_HOST=0.0.0.0 - GOCLAW_PORT=18790 - GOCLAW_CONFIG=/app/data/config.json - GOCLAW_GATEWAY_TOKEN=${GOCLAW_GATEWAY_TOKEN:-} - GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:-} - - GOCLAW_OWNER_IDS=${GOCLAW_OWNER_IDS:-} - # Provider (at least one required) - - GOCLAW_PROVIDER=${GOCLAW_PROVIDER:-} - - GOCLAW_MODEL=${GOCLAW_MODEL:-} - - GOCLAW_OPENROUTER_API_KEY=${GOCLAW_OPENROUTER_API_KEY:-} - - GOCLAW_ANTHROPIC_API_KEY=${GOCLAW_ANTHROPIC_API_KEY:-} - - GOCLAW_ANTHROPIC_BASE_URL=${GOCLAW_ANTHROPIC_BASE_URL:-} - - GOCLAW_OPENAI_API_KEY=${GOCLAW_OPENAI_API_KEY:-} - - GOCLAW_OPENAI_BASE_URL=${GOCLAW_OPENAI_BASE_URL:-} - - GOCLAW_MINIMAX_API_KEY=${GOCLAW_MINIMAX_API_KEY:-} - - GOCLAW_GROQ_API_KEY=${GOCLAW_GROQ_API_KEY:-} - - GOCLAW_DEEPSEEK_API_KEY=${GOCLAW_DEEPSEEK_API_KEY:-} - - GOCLAW_GEMINI_API_KEY=${GOCLAW_GEMINI_API_KEY:-} - - GOCLAW_MISTRAL_API_KEY=${GOCLAW_MISTRAL_API_KEY:-} - - GOCLAW_XAI_API_KEY=${GOCLAW_XAI_API_KEY:-} - - GOCLAW_COHERE_API_KEY=${GOCLAW_COHERE_API_KEY:-} - - GOCLAW_PERPLEXITY_API_KEY=${GOCLAW_PERPLEXITY_API_KEY:-} - # Channels - - GOCLAW_TELEGRAM_TOKEN=${GOCLAW_TELEGRAM_TOKEN:-} - - GOCLAW_DISCORD_TOKEN=${GOCLAW_DISCORD_TOKEN:-} - - GOCLAW_LARK_APP_ID=${GOCLAW_LARK_APP_ID:-} - - GOCLAW_LARK_APP_SECRET=${GOCLAW_LARK_APP_SECRET:-} - - GOCLAW_LARK_ENCRYPT_KEY=${GOCLAW_LARK_ENCRYPT_KEY:-} - - GOCLAW_LARK_VERIFICATION_TOKEN=${GOCLAW_LARK_VERIFICATION_TOKEN:-} - - GOCLAW_ZALO_TOKEN=${GOCLAW_ZALO_TOKEN:-} - - GOCLAW_WHATSAPP_BRIDGE_URL=${GOCLAW_WHATSAPP_BRIDGE_URL:-} # Debug - GOCLAW_TRACE_VERBOSE=${GOCLAW_TRACE_VERBOSE:-0} volumes: - goclaw-data:/app/data - - goclaw-workspace:/app/workspace # standalone mode workspace + - goclaw-workspace:/app/workspace security_opt: - no-new-privileges:true cap_drop: diff --git a/docs/00-architecture-overview.md b/docs/00-architecture-overview.md index 0273f08e..3d25e2f9 100644 --- a/docs/00-architecture-overview.md +++ b/docs/00-architecture-overview.md @@ -2,12 +2,7 @@ ## 1. Overview -GoClaw is an AI agent gateway written in Go. It exposes a WebSocket RPC (v3) interface and an OpenAI-compatible HTTP API for orchestrating LLM-powered agents. The system supports two operating modes: - -- **Standalone** -- file-based storage with SQLite for per-user data, zero external dependencies beyond an LLM API key. -- **Managed** -- PostgreSQL-backed multi-tenant mode with HTTP CRUD APIs, per-user context files, encrypted credentials, agent delegation, teams, and LLM call tracing. - -> **Documentation scope**: This documentation covers both modes. Standalone mode now has near-parity with managed mode for core features (per-user context files, workspace isolation, agent types, bootstrap onboarding). Managed mode adds agent delegation, teams, quality gates, tracing, HTTP CRUD APIs, and encrypted secrets. +GoClaw is an AI agent gateway written in Go. It exposes a WebSocket RPC (v3) interface and an OpenAI-compatible HTTP API for orchestrating LLM-powered agents. The system uses PostgreSQL as its storage backend with full multi-tenant isolation, per-user context files, encrypted credentials, agent delegation, teams, and LLM call tracing. ## 2. Component Diagram @@ -121,17 +116,16 @@ flowchart TD | `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/pg/` | PostgreSQL implementations (`database/sql` + `pgx/v5`) | -| `internal/store/file/` | File-based implementations: sessions, memory (SQLite), cron, pairing, skills, agents (filesystem + SQLite) | -| `internal/bootstrap/` | System prompt files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, BOOTSTRAP.md) + seeding + truncation | +| `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/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 (SQLite FTS5 + embeddings for standalone mode) | +| `internal/memory/` | Memory system (pgvector hybrid search) | | `internal/permissions/` | RBAC policy engine (admin, operator, viewer roles) | | `internal/pairing/` | DM/device pairing service (8-character codes) | -| `internal/sessions/` | File-based session manager (standalone mode) | +| `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 | @@ -139,37 +133,10 @@ flowchart TD | `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/heartbeat/` | Periodic agent wake-up service | --- -## 4. Two Operating Modes - -| Aspect | Standalone | Managed | -|--------|-----------|---------| -| Config source | `config.json` + env vars | `config.json` + `GOCLAW_POSTGRES_DSN` | -| Storage | JSON files + SQLite (`~/.goclaw/data/agents.db`) | PostgreSQL | -| Agents | Defined in `config.json` `agents.list`, created eagerly at startup | `agents` table, lazy-resolved via `ManagedResolver` | -| Agent store | `FileAgentStore` (filesystem + SQLite) | `PGAgentStore` | -| Context files | Agent-level on filesystem, per-user in SQLite | `agent_context_files` + `user_context_files` tables | -| Agent types | `open` / `predefined` (via config) | `open` (7 per-user files) / `predefined` (agent-level + USER.md per-user) | -| Per-user isolation | Workspace subdirectories (`user_alice/`, `user_bob/`) | Same + DB-scoped context files | -| Bootstrap onboarding | Per-user BOOTSTRAP.md seeding (SQLite) | Same (PostgreSQL) | -| Agent delegation | N/A | Sync/async delegation, agent links, quality gates | -| Agent teams | N/A | Shared task board, mailbox, handoff | -| Skills | Filesystem only (workspace + global dirs) | PostgreSQL + filesystem + embedding search | -| Memory | SQLite FTS5 + embeddings | pgvector hybrid (full-text search + vector similarity) | -| Tracing | N/A | `traces` + `spans` tables + optional OTel OTLP export | -| MCP servers | `config.json` `tools.mcp_servers` | `mcp_servers` table + grants | -| API key storage | `.env.local` / env vars only | PostgreSQL (AES-256-GCM encrypted) | -| HTTP CRUD API | N/A | `/v1/agents`, `/v1/skills`, `/v1/traces`, `/v1/mcp`, `/v1/delegations` | -| Virtual FS | `ContextFileInterceptor` routes to SQLite | `ContextFileInterceptor` routes to PostgreSQL | -| Custom tools | N/A | `custom_tools` table + `DynamicToolLoader` | -| Managed-only stores (nil in standalone) | -- | ProviderStore, TracingStore, MCPServerStore, CustomToolStore, AgentLinkStore, TeamStore | - ---- - -## 5. Multi-Tenant Identity Model +## 4. Multi-Tenant Identity Model GoClaw uses the **Identity Propagation** pattern (also known as **Trusted Subsystem**). It does not implement authentication or authorization — instead, it trusts the upstream service that authenticates with the gateway token to provide accurate user identity. @@ -196,8 +163,8 @@ flowchart LR | Entry Point | How user_id is provided | Enforcement | |-------------|------------------------|-------------| -| HTTP API | `X-GoClaw-User-Id` header | Required in managed mode | -| WebSocket | `user_id` field in `connect` handshake | Required in managed mode | +| HTTP API | `X-GoClaw-User-Id` header | Required | +| WebSocket | `user_id` field in `connect` handshake | Required | | Channels | Derived from platform sender ID (e.g., Telegram user ID) | Automatic | ### Compound User ID Convention @@ -239,36 +206,27 @@ sequenceDiagram GW->>GW: 2. Resolve workspace + data dirs GW->>GW: 3. Create Message Bus - alt Managed mode - GW->>PG: 4. Connect to Postgres (pg.NewPGStores) - PG-->>GW: PG stores created - GW->>GW: 5. Start tracing collector - GW->>PG: 6. Register providers from DB - GW->>PG: 7. Wire embedding provider to PGMemoryStore - GW->>PG: 8. Backfill memory embeddings (background) - else Standalone mode - GW->>GW: 4. Create file-based stores - end + GW->>PG: 4. Connect to Postgres (pg.NewPGStores) + PG-->>GW: PG stores created + GW->>GW: 5. Start tracing collector + GW->>PG: 6. Register providers from DB + GW->>PG: 7. Wire embedding provider to PGMemoryStore + GW->>PG: 8. Backfill memory embeddings (background) GW->>GW: 9. Register config-based providers GW->>GW: 10. Create tool registry (filesystem, exec, web, memory, browser, TTS, subagent, MCP) - GW->>GW: 11. Load bootstrap files (DB or filesystem) + GW->>GW: 11. Load bootstrap files (DB) GW->>GW: 12. Create skills loader + register skill_search tool - GW->>GW: 13. Wire skill embeddings (managed only) + GW->>GW: 13. Wire skill embeddings - alt Managed mode - GW->>GW: 14. Create agents lazily (set ManagedResolver) - GW->>GW: 15. wireManagedExtras (interceptors, cache subscribers) - GW->>GW: 16. Wire managed HTTP handlers (agents, skills, traces, MCP) - else Standalone mode - GW->>GW: 14. Create agents eagerly from config - GW->>GW: 15. wireStandaloneExtras (FileAgentStore, interceptors, callbacks) - end + GW->>GW: 14. Create agents lazily (set ManagedResolver) + GW->>GW: 15. wireManagedExtras (interceptors, cache subscribers) + GW->>GW: 16. Wire managed HTTP handlers (agents, skills, traces, MCP) GW->>Engine: 17. Create gateway server (WS + HTTP) GW->>Engine: 18. Register RPC methods GW->>Engine: 19. Register + start channels (Telegram, Discord, Feishu, Zalo, WhatsApp) - GW->>Engine: 20. Start cron, heartbeat, scheduler (4 lanes) + GW->>Engine: 20. Start cron, scheduler (4 lanes) GW->>Engine: 21. Start skills watcher + inbound consumer GW->>Engine: 22. Listen on host:port ``` @@ -294,8 +252,6 @@ flowchart TD W11["11. Evaluate Loop + Handoff
evaluate_loop tool + handoff tool"] ``` -A separate `wireStandaloneExtras()` in `cmd/gateway_standalone.go` wires the same core callbacks (user seeding, context file loading) using `FileAgentStore` instead of PostgreSQL. - ### Cache Invalidation Events | Event | Subscriber | Action | @@ -386,8 +342,7 @@ When the process receives SIGINT or SIGTERM: 1. Broadcast `shutdown` event to all connected WebSocket clients. 2. `channelMgr.StopAll()` -- stop all channel adapters. 3. `cronStore.Stop()` -- stop cron scheduler. -4. `heartbeatSvc.Stop()` -- stop heartbeat service. -5. `sandboxMgr.Stop()` + `ReleaseAll()` -- release Docker containers. +4. `sandboxMgr.Stop()` + `ReleaseAll()` -- release Docker containers. 6. `cancel()` -- cancel root context, propagating to consumer + scheduler. 7. Deferred cleanup: flush tracing collector, close memory store, close browser manager, stop scheduler lanes. 8. HTTP server shutdown with a **5-second timeout** (`context.WithTimeout`). @@ -420,7 +375,7 @@ flowchart TD | `agents` | defaults (provider, model, context_window) + list (per-agent overrides) | | `tools` | profile, allow/deny lists, exec_approval, web, browser, mcp_servers, rate_limit_per_hour | | `channels` | Per-channel: enabled, token, dm_policy, group_policy, allow_from | -| `database` | mode (standalone/managed); postgres_dsn read only from env var | +| `database` | postgres_dsn read only from env var | ### Secret Handling @@ -439,8 +394,7 @@ flowchart TD | `cmd/root.go` | Cobra CLI entry point, flag parsing | | `cmd/gateway.go` | Gateway startup orchestrator (`runGateway()`) | | `cmd/gateway_managed.go` | Managed mode wiring (`wireManagedExtras()`, `wireManagedHTTP()`) | -| `cmd/gateway_standalone.go` | Standalone mode wiring (`wireStandaloneExtras()`) | -| `cmd/gateway_callbacks.go` | Shared callbacks for managed + standalone (user seeding, context file loading) | +| `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_providers.go` | Provider registration (config-based + DB-based) | | `cmd/gateway_methods.go` | RPC method registration | @@ -472,7 +426,7 @@ flowchart TD | [05-channels-messaging.md](./05-channels-messaging.md) | Channel adapters, Telegram formatting, pairing, managed-mode user scoping | | [06-store-data-model.md](./06-store-data-model.md) | Store interfaces, PostgreSQL schema, session caching, custom tool store | | [07-bootstrap-skills-memory.md](./07-bootstrap-skills-memory.md) | Bootstrap files, skills system, memory, skills grants | -| [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md) | Scheduler lanes, cron lifecycle, heartbeat | +| [08-scheduling-cron.md](./08-scheduling-cron.md) | Scheduler lanes, cron lifecycle | | [09-security.md](./09-security.md) | Defense layers, encryption, rate limiting, RBAC, sandbox | | [10-tracing-observability.md](./10-tracing-observability.md) | Tracing collector, span hierarchy, OTel export, trace API | | [11-agent-teams.md](./11-agent-teams.md) | Agent teams, task board, mailbox, delegation integration | diff --git a/docs/01-agent-loop.md b/docs/01-agent-loop.md index 8f1e59e5..dbf47374 100644 --- a/docs/01-agent-loop.md +++ b/docs/01-agent-loop.md @@ -171,8 +171,7 @@ The system prompt is assembled dynamically from 15+ sections. Two modes control 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. **Heartbeats** -- instructions for periodic wake-up behavior. -17. **Sub-Agent Spawning** -- rules for launching child agents. +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). diff --git a/docs/03-tools-system.md b/docs/03-tools-system.md index b8a4d79f..40fca43d 100644 --- a/docs/03-tools-system.md +++ b/docs/03-tools-system.md @@ -162,7 +162,6 @@ flowchart TD | `IDENTITY.md` | Agent identity information | | `AGENTS.md` | Sub-agent definitions | | `TOOLS.md` | Tool usage guidance | -| `HEARTBEAT.md` | Periodic wake-up instructions | | `USER.md` | Per-user preferences and context | | `BOOTSTRAP.md` | First-run instructions (write empty = delete row) | diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 1b83066b..c93acc26 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -292,7 +292,6 @@ flowchart TD | Method | Description | |--------|-------------| | `logs.tail` | Tail gateway logs | -| `heartbeat` | Trigger heartbeat check | --- diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index f8605580..b4a106ff 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -182,7 +182,7 @@ The Telegram channel uses long polling via the `telego` library (Telegram Bot AP - **Group mention gating**: By default, bot must be @mentioned in groups (`requireMention: true`). Pending messages without a mention are stored in a history buffer (default 50 messages) and included as context when the bot is eventually mentioned. - **Typing indicator**: A "typing" action is sent while the agent is processing. - **Proxy support**: Optional HTTP proxy configured via channel config. -- **Cancel commands**: `/stop` and `/stopall` intercepted before the 800ms debouncer. See [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md). +- **Cancel commands**: `/stop` and `/stopall` intercepted before the 800ms debouncer. See [08-scheduling-cron.md](./08-scheduling-cron.md) for details. - **Concurrent group support**: Group sessions support up to 3 concurrent agent runs. - **Bot reply as implicit mention**: Replying to a bot message in a group counts as mentioning the bot. @@ -478,13 +478,13 @@ All channel state — placeholders, streams, reactions, typing controllers, thre --- -## 13. Managed Mode Behavior +## 13. Per-User Isolation -In managed mode, channels provide per-user isolation through compound sender IDs and context propagation: +Channels provide per-user isolation through compound sender IDs and context propagation: - **User scoping**: Each channel constructs a compound sender ID (e.g., `telegram:123456`) which maps to a `user_id`. The session key format `agent:{agentId}:{channel}:direct:{peerId}` ensures each user has isolated conversation history per agent. - **Context propagation**: `HandleMessage()` injects `AgentID`, `UserID`, and `AgentType` into the context. These flow to the ContextFileInterceptor, MemoryInterceptor, and per-user file seeding. -- **Pairing storage**: PostgreSQL (`pairing_requests` and `paired_devices` tables) in managed mode; JSON files in standalone mode. +- **Pairing storage**: PostgreSQL (`pairing_requests` and `paired_devices` tables). - **Session persistence**: PostgreSQL `sessions` table with write-behind caching. --- @@ -554,6 +554,6 @@ flowchart TD |----------|-----------------| | [00-architecture-overview.md](./00-architecture-overview.md) | Channel startup in gateway sequence | | [03-tools-system.md](./03-tools-system.md) | Tool policy engine, per-request tool allow list | -| [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md) | /stop and /stopall commands, scheduler lanes | +| [08-scheduling-cron.md](./08-scheduling-cron.md) | /stop and /stopall commands, scheduler lanes, cron | | [09-security.md](./09-security.md) | Group file writer restrictions, security logging | | [11-agent-teams.md](./11-agent-teams.md) | Team message routing, delegation result delivery | diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index af71bd0e..809ddd8a 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -1,58 +1,40 @@ # 06 - Store Layer and Data Model -The store layer abstracts all persistence behind Go interfaces, allowing the same core engine to run with file-based storage (standalone mode) or PostgreSQL (managed mode). Each store interface has independent implementations, and the system determines which backend to use based on configuration at startup. +The store layer abstracts all persistence behind Go interfaces backed by PostgreSQL. Each store interface has a PostgreSQL implementation wired at startup. --- -## 1. Store Layer Routing +## 1. Store Layer ```mermaid flowchart TD - START["Gateway Startup"] --> CHECK{"StoreConfig.IsManaged()?
(DSN + mode = managed)"} - CHECK -->|Yes| PG["PostgreSQL Backend"] - CHECK -->|No| FILE["File Backend"] + START["Gateway Startup"] --> PG["PostgreSQL Backend"] PG --> PG_STORES["PGSessionStore
PGAgentStore
PGProviderStore
PGCronStore
PGPairingStore
PGSkillStore
PGMemoryStore
PGTracingStore
PGMCPServerStore
PGCustomToolStore
PGChannelInstanceStore
PGConfigSecretsStore
PGAgentLinkStore
PGTeamStore"] - - FILE --> FILE_STORES["FileSessionStore
FileMemoryStore (SQLite + FTS5)
FileCronStore
FilePairingStore
FileSkillStore
FileAgentStore (filesystem + SQLite)
ProviderStore = nil
TracingStore = nil
MCPServerStore = nil
CustomToolStore = nil
AgentLinks = nil
Teams = nil"] ``` --- ## 2. Store Interface Map -The `Stores` struct is the top-level container holding all storage backends. In standalone mode, managed-only stores are `nil`. +The `Stores` struct is the top-level container holding all PostgreSQL-backed storage implementations. -| Interface | Standalone Implementation | Managed Implementation | Mode | -|-----------|--------------------------|------------------------|------| -| SessionStore | `FileSessionStore` via `sessions.Manager` | `PGSessionStore` | Both | -| MemoryStore | `FileMemoryStore` (SQLite + FTS5 + embeddings) | `PGMemoryStore` (tsvector + pgvector) | Both | -| CronStore | `FileCronStore` | `PGCronStore` | Both | -| PairingStore | `FilePairingStore` via `pairing.Service` | `PGPairingStore` | Both | -| SkillStore | `FileSkillStore` via `skills.Loader` | `PGSkillStore` | Both | -| AgentStore | `FileAgentStore` (filesystem + SQLite) | `PGAgentStore` | Both | -| ProviderStore | `nil` | `PGProviderStore` | Managed only | -| TracingStore | `nil` | `PGTracingStore` | Managed only | -| MCPServerStore | `nil` | `PGMCPServerStore` | Managed only | -| CustomToolStore | `nil` | `PGCustomToolStore` | Managed only | -| ChannelInstanceStore | `nil` | `PGChannelInstanceStore` | Managed only | -| ConfigSecretsStore | `nil` | `PGConfigSecretsStore` | Managed only | -| AgentLinkStore | `nil` | `PGAgentLinkStore` | Managed only | -| TeamStore | `nil` | `PGTeamStore` | Managed only | - -### Standalone AgentStore (FileAgentStore) - -In standalone mode, `FileAgentStore` provides per-user context files and profiles without PostgreSQL. It combines filesystem storage (agent-level files like SOUL.md) with SQLite (`~/.goclaw/data/agents.db`) for per-user data: - -| Data | Storage | -|------|---------| -| Agent metadata | In-memory from `config.json` | -| Agent-level files (SOUL.md, IDENTITY.md, ...) | Filesystem at workspace root | -| Per-user files (USER.md, BOOTSTRAP.md) | SQLite `user_context_files` | -| User profiles | SQLite `user_profiles` | -| Group file writers | SQLite `group_file_writers` | - -Agent UUIDs use UUID v5 (deterministic): `uuid.NewSHA1(namespace, "goclaw-standalone:{agentKey}")` -- stable across restarts without database sequences. +| 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` | --- @@ -70,7 +52,7 @@ flowchart TD CACHE --> GETSM["GetSummary()"] end - CACHE -->|"Save(key)"| DB[("PostgreSQL / JSON file")] + CACHE -->|"Save(key)"| DB[("PostgreSQL")] DB -->|"Cache miss via GetOrCreate"| CACHE ``` @@ -91,12 +73,6 @@ flowchart TD | Cron | `agent:{agentId}:cron:{jobId}:run:{runId}` | `agent:default:cron:reminder:run:abc123` | | Main | `agent:{agentId}:{mainKey}` | `agent:default:main` | -### File-Based Persistence (Standalone) - -- Startup: `loadAll()` reads all `.json` files into memory -- Save: temp file + rename (atomic write, prevents corruption on crash) -- Filename: session key with `:` replaced by `_`, plus `.json` extension - --- ## 4. Agent Access Control @@ -182,14 +158,14 @@ flowchart TD When FTS returns no results (e.g., cross-language queries), a `likeSearch()` fallback runs ILIKE queries using up to 5 keywords (minimum 3 characters each), scoped to the agent's index. -### Standalone vs Managed +### Search Implementation -| Aspect | Standalone | Managed | -|--------|-----------|---------| -| FTS engine | SQLite FTS5 | PostgreSQL tsvector | -| Vector | Embedding cache | pgvector extension | -| Search function | `plainto_tsquery('simple', ...)` | Same | -| Distance operator | N/A | `<=>` (cosine) | +| Aspect | Detail | +|--------|--------| +| FTS engine | PostgreSQL tsvector | +| Vector | pgvector extension | +| Search function | `plainto_tsquery('simple', ...)` | +| Distance operator | `<=>` (cosine) | --- @@ -208,8 +184,8 @@ Context files are stored in two tables and routed based on agent type. | Agent Type | Agent-Level Files | Per-User Files | |------------|-------------------|----------------| -| `open` | Template fallback only | All 7 files (SOUL, IDENTITY, AGENTS, TOOLS, HEARTBEAT, BOOTSTRAP, USER) | -| `predefined` | 6 files (SOUL, IDENTITY, AGENTS, TOOLS, HEARTBEAT, BOOTSTRAP) | Only USER.md | +| `open` | Template fallback only | All files (SOUL, IDENTITY, AGENTS, TOOLS, BOOTSTRAP, USER) | +| `predefined` | Agent-level files (SOUL, IDENTITY, AGENTS, TOOLS, BOOTSTRAP) | Only USER.md | The `ContextFileInterceptor` checks agent type from context and routes read/write operations accordingly. For open agents, per-user files take priority with agent-level as fallback. @@ -577,7 +553,6 @@ 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/file/agents.go` | `FileAgentStore`: filesystem + SQLite backend for standalone mode | | `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 | diff --git a/docs/07-bootstrap-skills-memory.md b/docs/07-bootstrap-skills-memory.md index bd0ead38..a1b125e0 100644 --- a/docs/07-bootstrap-skills-memory.md +++ b/docs/07-bootstrap-skills-memory.md @@ -22,8 +22,7 @@ Markdown files loaded at agent initialization and embedded into the system promp | 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 | HEARTBEAT.md | Periodic check task list | Yes | No | -| 7 | BOOTSTRAP.md | First-run ritual (deleted after completion) | Yes | No | +| 6 | BOOTSTRAP.md | First-run ritual (deleted after completion) | Yes | No | Subagent and cron sessions load only AGENTS.md + TOOLS.md (the `minimalAllowlist`). @@ -60,28 +59,11 @@ 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 workspaces or new users. +Templates are embedded in the binary via Go `embed` (directory: `internal/bootstrap/templates/`). Seeding automatically creates default files for new users. ```mermaid flowchart TD - subgraph "Standalone Mode" - SA["EnsureWorkspaceFiles()"] --> SA1["Iterate over embedded templates"] - SA1 --> SA2{"File already exists?
(O_EXCL atomic check)"} - SA2 -->|Yes| SKIP1["Skip"] - SA2 -->|No| CREATE1["Create template file on disk"] - end - - subgraph "Standalone Mode -- Per-User (FileAgentStore)" - SU["SeedUserFiles()"] --> SU1{"Agent type?"} - SU1 -->|open| SU_OPEN["Seed all 7 files to SQLite"] - SU1 -->|predefined| SU_PRED["Seed USER.md + BOOTSTRAP.md to SQLite"] - SU_OPEN --> SU_CHK{"Row already exists?"} - SU_PRED --> SU_CHK - SU_CHK -->|Yes| SKIP_SU["Skip"] - SU_CHK -->|No| SU_WRITE["INSERT into user_context_files"] - end - - subgraph "Managed Mode -- Agent Level" + 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)"] @@ -90,7 +72,7 @@ flowchart TD SB3 -->|No| WRITE2["Write embedded template"] end - subgraph "Managed Mode -- Per-User" + 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"] @@ -103,31 +85,26 @@ flowchart TD `SeedUserFiles()` is idempotent -- safe to call multiple times without overwriting personalized content. -### Standalone UUID Generation - -Standalone agents are defined in `config.json` without database-generated UUIDs. `FileAgentStore` uses UUID v5 (`uuid.NewSHA1(namespace, "goclaw-standalone:{agentKey}")`) to produce deterministic IDs from agent keys. This ensures SQLite rows for per-user files survive process restarts without coordination. - ### Predefined Agent Bootstrap -Both standalone and managed mode now seed `BOOTSTRAP.md` 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 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. --- ## 4. Agent Type Routing -Two agent types determine which context files live at the agent level versus the per-user level. Agent types are now available in both managed and standalone modes. +Two agent types determine which context files live at the agent level versus the per-user level. | Agent Type | Agent-Level Files | Per-User Files | |------------|-------------------|----------------| -| `open` | None | All 7 files (AGENTS, SOUL, TOOLS, IDENTITY, USER, HEARTBEAT, BOOTSTRAP) | +| `open` | None | All files (AGENTS, SOUL, TOOLS, IDENTITY, USER, BOOTSTRAP) | | `predefined` | 6 files (shared across all users) | USER.md + BOOTSTRAP.md | 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). -| Mode | Agent Type Source | Per-User Storage | -|------|------------------|-----------------| -| Managed | `agents` PostgreSQL table | `user_context_files` table | -| Standalone | `config.json` agent entries | SQLite via `FileAgentStore` | +| Source | Per-User Storage | +|--------|-----------------| +| `agents` PostgreSQL table | `user_context_files` table | --- @@ -155,8 +132,7 @@ flowchart TD S9 --> S10["10. Extra Context / Subagent Context"] S10 --> S11["11. Project Context
(bootstrap files + virtual files)"] S11 --> S12["12. Silent Replies (full only)"] - S12 --> S13["13. Heartbeats (full only)"] - S13 --> S14["14. Sub-Agent Spawning (conditional)"] + S12 --> S14["14. Sub-Agent Spawning (conditional)"] S14 --> S15["15. Runtime"] ``` @@ -178,7 +154,6 @@ flowchart TD | 10. Extra Context | Conditional | Conditional | | 11. Project Context | Yes | Yes | | 12. Silent Replies | Yes | No | -| 13. Heartbeats | Yes | No | | 14. Sub-Agent Spawning | Conditional | Conditional | | 15. Runtime | Yes | Yes | @@ -213,7 +188,7 @@ This ensures resolver-injected virtual files (`DELEGATION.md`, `TEAM.md`) surviv ## 7. Agent Summoning (Managed Mode) -Creating a predefined agent requires 5 context files (SOUL.md, IDENTITY.md, AGENTS.md, TOOLS.md, HEARTBEAT.md) with specific formatting conventions. Agent summoning generates all 5 files from a natural language description in a single LLM call. +Creating a predefined agent requires 4 context files (SOUL.md, IDENTITY.md, AGENTS.md, TOOLS.md) with specific formatting conventions. Agent summoning generates all 4 files from a natural language description in a single LLM call. ```mermaid flowchart TD @@ -432,8 +407,8 @@ Combines full-text search and vector search with weighted merging. ```mermaid flowchart TD - Q["Search(query)"] --> FTS["FTS Search
Standalone: SQLite FTS5 (BM25)
Managed: tsvector + plainto_tsquery"] - Q --> VEC["Vector Search
Standalone: cosine similarity
Managed: pgvector (cosine distance)"] + Q["Search(query)"] --> FTS["FTS Search
tsvector + plainto_tsquery"] + Q --> VEC["Vector Search
pgvector (cosine distance)"] FTS --> MERGE["hybridMerge()"] VEC --> MERGE MERGE --> NORM["Normalize FTS scores to 0..1
Vector scores already in 0..1"] @@ -442,15 +417,14 @@ flowchart TD BOOST --> RESULT["Sorted + filtered results"] ``` -### Standalone vs Managed Comparison +### Search Implementation -| Aspect | Standalone | Managed | -|--------|-----------|---------| -| Storage | SQLite + FTS5 | PostgreSQL + tsvector + pgvector | -| FTS | `porter unicode61` tokenizer | `plainto_tsquery('simple')` | -| Vector | JSON array embedding | pgvector type | -| Scope | Global (single agent) | Per-agent + per-user | -| File watcher | fsnotify (1500ms debounce) | Not needed (DB-backed) | +| Aspect | Detail | +|--------|--------| +| Storage | PostgreSQL + tsvector + pgvector | +| FTS | `plainto_tsquery('simple')` | +| Vector | pgvector type | +| Scope | Per-agent + per-user | When both FTS and vector search return results, scores are merged using the weighted sum. When only one channel returns results, its scores are used directly (weights normalized to 1.0). @@ -492,8 +466,7 @@ The flush is idempotent per compaction cycle -- it will not run again until the |------|-------------| | `internal/bootstrap/files.go` | Bootstrap file constants, loading, session filtering | | `internal/bootstrap/truncate.go` | Truncation pipeline (head/tail split, budget clamping) | -| `internal/bootstrap/seed.go` | Standalone mode seeding (EnsureWorkspaceFiles) | -| `internal/bootstrap/seed_store.go` | Managed mode seeding (SeedToStore, SeedUserFiles) | +| `internal/bootstrap/seed_store.go` | Seeding: SeedToStore, SeedUserFiles | | `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) | @@ -501,7 +474,6 @@ The flush is idempotent per compaction cycle -- it will not run again until the | `internal/agent/resolver.go` | Agent resolution, DELEGATION.md + TEAM.md injection | | `internal/agent/loop_history.go` | Context file merging (base + per-user, base-only preserved) | | `internal/agent/memoryflush.go` | Memory flush logic (shouldRunMemoryFlush, runMemoryFlush) | -| `internal/store/file/agents.go` | FileAgentStore -- filesystem + SQLite backend for standalone | | `internal/http/summoner.go` | Agent summoning -- LLM-powered context file generation | | `internal/skills/loader.go` | Skill loader (5-tier hierarchy, BuildSummary, filtering) | | `internal/skills/search.go` | BM25 search index (tokenization, IDF scoring) | diff --git a/docs/08-scheduling-cron-heartbeat.md b/docs/08-scheduling-cron.md similarity index 71% rename from docs/08-scheduling-cron-heartbeat.md rename to docs/08-scheduling-cron.md index d6f22beb..16b8b5e7 100644 --- a/docs/08-scheduling-cron-heartbeat.md +++ b/docs/08-scheduling-cron.md @@ -1,14 +1,13 @@ -# 08 - Scheduling, Cron & Heartbeat +# 08 - Scheduling & Cron -Concurrency control and periodic task execution. The scheduler provides lane-based isolation and per-session serialization. Cron and heartbeat extend the agent loop with time-triggered behavior. +Concurrency control and periodic task execution. The scheduler provides lane-based isolation and per-session serialization. Cron extends the agent loop with time-triggered behavior. -> **Managed mode**: Cron jobs and run logs are stored in the `cron_jobs` and `cron_run_logs` PostgreSQL tables. Cache invalidation propagates via the `cache:cron` event on the message bus. In standalone mode, cron state is persisted to JSON files. +> Cron jobs and run logs are stored in the `cron_jobs` and `cron_run_logs` PostgreSQL tables. Cache invalidation propagates via the `cache:cron` event on the message bus. ### Responsibilities - Scheduler: lane-based concurrency control, per-session message queue serialization - Cron: three schedule kinds (at/every/cron), run logging, retry with exponential backoff -- Heartbeat: periodic agent wake-up, HEARTBEAT_OK detection, dedup within 24h --- @@ -165,45 +164,6 @@ Jobs can be `active` or `paused`. Paused jobs skip execution during the due chec --- -## 5. Heartbeat -- 5 Steps - -Periodically wakes the agent to check on events (calendar, inbox, alerts) and surfaces anything that needs attention. - -> **Standalone mode only in practice**: HEARTBEAT.md is seeded from bootstrap templates only in standalone mode. In managed mode, HEARTBEAT.md is excluded from seeding (replaced by cron jobs). The heartbeat service still runs in both modes, but without a HEARTBEAT.md file with content, Step 2 skips execution. - -```mermaid -flowchart TD - TICK["tick() -- every interval (default 30 min)"] --> S1{"Step 1:
Within Active Hours?"} - S1 -->|Outside hours| SKIP1["Skip"] - S1 -->|Within hours| S2{"Step 2:
HEARTBEAT.md exists
and has meaningful content?"} - S2 -->|No| SKIP2["Skip"] - S2 -->|Yes| S3["Step 3: runner()
Run agent with heartbeat prompt"] - S3 --> S4{"Step 4:
Reply contains HEARTBEAT_OK?"} - S4 -->|OK| LOG["Log debug, discard reply"] - S4 -->|Has content| S5{"Step 5:
Dedup -- same content
within 24h?"} - S5 -->|Duplicate| SKIP3["Skip"] - S5 -->|New| DELIVER["deliver() via resolveTarget()
then msgBus.PublishOutbound()"] -``` - -### Heartbeat Configuration - -| Parameter | Default | Description | -|-----------|---------|-------------| -| Interval (`every`) | 30 minutes | Time between heartbeat wakes (`"0m"` to disable) | -| ActiveHours | (none) | Time window restriction, supports wrap-around midnight | -| Target | `"last"` | `"last"` (last-used channel), `"none"`, or explicit channel name | -| To | (empty) | Explicit chat ID (overrides target resolver) | -| Prompt | (default) | Custom system prompt for heartbeat runs | -| Model | (agent default) | Override LLM model for heartbeat runs | -| Session | `agent:{id}:heartbeat:main` | Custom session key | -| AckMaxChars | 300 | Content alongside HEARTBEAT_OK up to this length is still treated as OK | - -### HEARTBEAT_OK Detection - -Recognizes multiple formatting variants: `HEARTBEAT_OK`, `**HEARTBEAT_OK**`, `` `HEARTBEAT_OK` ``, `HEARTBEAT_OK`. Content accompanying the token is treated as an acknowledgment (OK) if it does not exceed `AckMaxChars`. - ---- - ## File Reference | File | Description | @@ -212,13 +172,11 @@ Recognizes multiple formatting variants: `HEARTBEAT_OK`, `**HEARTBEAT_OK**`, `` | `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/heartbeat/service.go` | Heartbeat loop, HEARTBEAT_OK detection, active hours | | `internal/store/cron_store.go` | CronStore interface (jobs + run logs) | -| `internal/store/file/cron.go` | File-based cron implementation (standalone mode) | | `internal/store/pg/cron.go` | PostgreSQL cron implementation | | `internal/store/pg/cron_scheduler.go` | PG job cache, due-job detection, execution | | `cmd/gateway_cron.go` | makeCronJobHandler (routes cron execution to scheduler) | -| `cmd/gateway_agents.go` | setupHeartbeat, agent initialization | +| `cmd/gateway_agents.go` | Agent initialization | | `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 3ee8395d..b8b4ab90 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -368,5 +368,5 @@ When concurrency limits are hit, the error message is written for LLM reasoning: | [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 | | [07-bootstrap-skills-memory.md](./07-bootstrap-skills-memory.md) | Context file merging, virtual files | -| [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md) | Scheduler lanes, cron lifecycle | +| [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 67875db1..3916080f 100644 --- a/docs/10-tracing-observability.md +++ b/docs/10-tracing-observability.md @@ -2,7 +2,7 @@ Records agent run activities asynchronously. Spans are buffered in memory and flushed to the TracingStore in batches, with optional export to external OpenTelemetry backends. -> **Managed mode only**: Tracing requires PostgreSQL. In standalone mode, `TracingStore` is nil and no traces are recorded. The `traces` and `spans` tables store all tracing data. Optional OTel export sends spans to external backends (Jaeger, Grafana Tempo, Datadog) in addition to PostgreSQL. +> Tracing uses PostgreSQL. The `traces` and `spans` tables store all tracing data. Optional OTel export sends spans to external backends (Jaeger, Grafana Tempo, Datadog) in addition to PostgreSQL. --- @@ -163,5 +163,5 @@ Delegation history is automatically recorded by `DelegateManager.saveDelegationH | [01-agent-loop.md](./01-agent-loop.md) | Span emission during agent execution, cancel handling | | [03-tools-system.md](./03-tools-system.md) | Delegation system, delegation history via agent tool | | [06-store-data-model.md](./06-store-data-model.md) | traces/spans tables schema, delegation_history table | -| [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md) | /stop and /stopall commands | +| [08-scheduling-cron.md](./08-scheduling-cron.md) | Scheduler lanes, /stop and /stopall commands | | [09-security.md](./09-security.md) | Rate limiting, RBAC access control | diff --git a/docs/11-agent-teams.md b/docs/11-agent-teams.md index 53b579a2..3c769525 100644 --- a/docs/11-agent-teams.md +++ b/docs/11-agent-teams.md @@ -435,5 +435,5 @@ Teams emit events for real-time UI updates and observability. |----------|-----------------| | [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 | -| [08-scheduling-cron-heartbeat.md](./08-scheduling-cron-heartbeat.md) | Delegate scheduler lane (concurrency 100) | +| [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/internal/agent/loop.go b/internal/agent/loop.go index 4c114171..0e04a5b7 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -48,7 +48,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { Payload: map[string]interface{}{"message": req.Message}, }) - // Create trace (managed mode only) + // Create trace var traceID uuid.UUID isChildTrace := req.ParentTraceID != uuid.Nil && l.traceCollector != nil @@ -163,7 +163,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) l.emit(event) } - // Inject agent UUID into context for tool routing (managed mode) + // Inject agent UUID into context for tool routing if l.agentUUID != uuid.Nil { ctx = store.WithAgentID(ctx, l.agentUUID) } @@ -171,7 +171,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if req.UserID != "" { ctx = store.WithUserID(ctx, req.UserID) } - // Inject agent type into context for interceptor routing (managed mode) + // Inject agent type into context for interceptor routing if l.agentType != "" { ctx = store.WithAgentType(ctx, l.agentType) } @@ -200,7 +200,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) cachedWs, loaded := l.userWorkspaces.Load(req.UserID) if !loaded { // First request for this user: get/create profile → returns stored workspace. - // Also seeds per-user context files on first chat (managed mode). + // Also seeds per-user context files on first chat. ws := l.workspace if l.ensureUserFiles != nil { var err error @@ -258,7 +258,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } - // Inject agent key into context for tool-level resolution (managed mode: multiple agents share tool registry) + // Inject agent key into context for tool-level resolution (multiple agents share tool registry) ctx = tools.WithToolAgentKey(ctx, l.id) // Security: truncate oversized user messages gracefully (feed truncation notice into LLM) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 4a473d7f..525a66c8 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -30,7 +30,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s _, hasSpawn := l.tools.Get("spawn") _, hasSkillSearch := l.tools.Get("skill_search") - // Per-user workspace: show the user's subdirectory in the system prompt (managed mode). + // Per-user workspace: show the user's subdirectory in the system prompt. // Uses cached workspace from user_agent_profiles (includes channel isolation). promptWorkspace := l.workspace if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" { @@ -51,7 +51,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s } } - // Group writer restrictions: filter context files + inject prompt (managed mode only) + // Group writer restrictions: filter context files + inject prompt if l.groupWriterCache != nil && strings.HasPrefix(userID, "group:") { senderID := store.SenderIDFromContext(ctx) writerPrompt, filtered := l.buildGroupWriterPrompt(ctx, userID, senderID, contextFiles) diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 40e4ac9e..13ff6b82 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -22,11 +22,11 @@ import ( // Bootstrap typically completes in 2-3 conversation turns. const bootstrapAutoCleanupTurns = 3 -// EnsureUserFilesFunc seeds per-user context files on first chat (managed mode). +// EnsureUserFilesFunc seeds per-user context files on first chat. // Returns the effective workspace path (from user_agent_profiles) for caching. type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error) -// ContextFileLoaderFunc loads context files dynamically per-request (managed mode). +// ContextFileLoaderFunc loads context files dynamically per-request. type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile // BootstrapCleanupFunc removes BOOTSTRAP.md after a successful first run. @@ -37,8 +37,8 @@ type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID st // Think → Act → Observe cycle with tool execution. type Loop struct { id string - agentUUID uuid.UUID // set in managed mode for context propagation - agentType string // "open" or "predefined" (managed mode) + agentUUID uuid.UUID // set for context propagation + agentType string // "open" or "predefined" provider providers.Provider model string contextWindow int @@ -63,7 +63,7 @@ type Loop struct { hasMemory bool contextFiles []bootstrap.ContextFile - // Per-user file seeding + dynamic context loading (managed mode) + // Per-user file seeding + dynamic context loading ensureUserFiles EnsureUserFilesFunc contextFileLoader ContextFileLoaderFunc bootstrapCleanup BootstrapCleanupFunc @@ -83,7 +83,7 @@ type Loop struct { // Event callback for broadcasting agent events (run.started, chunk, tool.call, etc.) onEvent func(event AgentEvent) - // Tracing collector (nil in standalone mode) + // Tracing collector (nil if not configured) traceCollector *tracing.Collector // Security: input scanning and message size limit @@ -91,16 +91,16 @@ type Loop struct { injectionAction string // "log", "warn" (default), "block", "off" maxMessageChars int // 0 = use default (32000) - // Global builtin tool settings (from builtin_tools table, managed mode) + // Global builtin tool settings (from builtin_tools table) builtinToolSettings tools.BuiltinToolSettings // Thinking level for extended thinking support thinkingLevel string - // Group writer cache for system prompt injection (managed mode) + // Group writer cache for system prompt injection groupWriterCache *store.GroupWriterCache - // Team store for cross-session pending task detection (managed mode) + // Team store for cross-session pending task detection teamStore store.TeamStore } @@ -158,11 +158,11 @@ type LoopConfig struct { SandboxContainerDir string // e.g. "/workspace" SandboxWorkspaceAccess string // "none", "ro", "rw" - // Managed mode: agent UUID for context propagation to tools + // Agent UUID for context propagation to tools AgentUUID uuid.UUID - AgentType string // "open" or "predefined" (managed mode) + AgentType string // "open" or "predefined" - // Per-user file seeding + dynamic context loading (managed mode) + // Per-user file seeding + dynamic context loading EnsureUserFiles EnsureUserFilesFunc ContextFileLoader ContextFileLoaderFunc BootstrapCleanup BootstrapCleanupFunc @@ -175,16 +175,16 @@ type LoopConfig struct { InjectionAction string // "log", "warn" (default), "block", "off" MaxMessageChars int // 0 = use default (32000) - // Global builtin tool settings (from builtin_tools table, managed mode) + // Global builtin tool settings (from builtin_tools table) BuiltinToolSettings tools.BuiltinToolSettings // Thinking level: "off", "low", "medium", "high" (from agent other_config) ThinkingLevel string - // Group writer cache for system prompt injection (managed mode) + // Group writer cache for system prompt injection GroupWriterCache *store.GroupWriterCache - // Team store for cross-session pending task detection (managed mode) + // Team store for cross-session pending task detection TeamStore store.TeamStore } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index aba039d1..7a719389 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -20,7 +20,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tracing" ) -// ResolverDeps holds shared dependencies for the managed-mode agent resolver. +// ResolverDeps holds shared dependencies for the agent resolver. type ResolverDeps struct { AgentStore store.AgentStore ProviderReg *providers.Registry @@ -33,7 +33,7 @@ type ResolverDeps struct { OnEvent func(AgentEvent) TraceCollector *tracing.Collector - // Per-user file seeding + dynamic context loading (managed mode) + // Per-user file seeding + dynamic context loading EnsureUserFiles EnsureUserFilesFunc ContextFileLoader ContextFileLoaderFunc BootstrapCleanup BootstrapCleanupFunc @@ -49,30 +49,30 @@ type ResolverDeps struct { SandboxContainerDir string SandboxWorkspaceAccess string - // Dynamic custom tools (managed mode) - DynamicLoader *tools.DynamicToolLoader // nil if not managed + // Dynamic custom tools + DynamicLoader *tools.DynamicToolLoader - // Inter-agent delegation (managed mode) - AgentLinkStore store.AgentLinkStore // nil if not managed or no links + // Inter-agent delegation + AgentLinkStore store.AgentLinkStore - // Agent teams (managed mode) - TeamStore store.TeamStore // nil if not managed or no teams + // Agent teams + TeamStore store.TeamStore - // Builtin tool settings (managed mode) - BuiltinToolStore store.BuiltinToolStore // nil if not managed + // Builtin tool settings + BuiltinToolStore store.BuiltinToolStore - // MCP server store (managed mode) — for per-agent MCP tool loading - MCPStore store.MCPServerStore // nil if not managed or no MCP + // MCP server store — for per-agent MCP tool loading + MCPStore store.MCPServerStore - // Skill access store (managed mode) — for per-agent skill visibility filtering - SkillAccessStore store.SkillAccessStore // nil if not managed + // Skill access store — for per-agent skill visibility filtering + SkillAccessStore store.SkillAccessStore - // Group file writer cache (managed mode) + // Group file writer cache GroupWriterCache *store.GroupWriterCache } // NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data. -// This is the core of managed mode: agents are defined in Postgres, not config.json. +// Agents are defined in Postgres, not config.json. func NewManagedResolver(deps ResolverDeps) ResolverFunc { return func(agentKey string) (Agent, error) { ctx := context.Background() diff --git a/internal/agent/router.go b/internal/agent/router.go index 575dda61..b4247a73 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -8,7 +8,7 @@ import ( ) // ResolverFunc is called when an agent isn't found in the cache. -// Used in managed mode to lazy-create agents from DB. +// Used to lazy-create agents from DB. type ResolverFunc func(agentKey string) (Agent, error) const defaultRouterTTL = 10 * time.Minute @@ -21,12 +21,12 @@ type agentEntry struct { // Router manages multiple agent Loop instances. // Each agent has a unique ID and its own provider/model/tools config. -// In managed mode, cached Loops expire after TTL (safety net for multi-instance). +// Cached Loops expire after TTL (safety net for multi-instance). type Router struct { agents map[string]*agentEntry mu sync.RWMutex activeRuns sync.Map // runID → *ActiveRun - resolver ResolverFunc // optional: lazy creation from DB (managed mode) + resolver ResolverFunc // optional: lazy creation from DB ttl time.Duration } @@ -37,7 +37,7 @@ func NewRouter() *Router { } } -// SetResolver sets a resolver function for lazy agent creation (managed mode). +// SetResolver sets a resolver function for lazy agent creation. func (r *Router) SetResolver(fn ResolverFunc) { r.mu.Lock() defer r.mu.Unlock() @@ -51,7 +51,7 @@ func (r *Router) Register(ag Agent) { r.agents[ag.ID()] = &agentEntry{agent: ag, cachedAt: time.Now()} } -// Get returns an agent by ID. In managed mode, lazy-creates from DB via resolver. +// Get returns an agent by ID. Lazy-creates from DB via resolver if needed. // Cached entries expire after TTL as a safety net for multi-instance deployments. func (r *Router) Get(agentID string) (Agent, error) { r.mu.RLock() @@ -70,7 +70,7 @@ func (r *Router) Get(agentID string) (Agent, error) { r.mu.Unlock() } - // Try resolver (managed mode: create from DB) + // Try resolver (create from DB) if resolver != nil { ag, err := resolver(agentID) if err != nil { diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 4c1b61e6..4ae2637f 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -153,12 +153,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { lines = append(lines, buildSilentRepliesSection()...) } - // 13. ## Heartbeats (full only) - if !isMinimal { - lines = append(lines, buildHeartbeatsSection()...) - } - - // 14. ## Sub-Agent Spawning + // 13. ## Sub-Agent Spawning if cfg.HasSpawn { lines = append(lines, buildSpawnSection()...) } diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 6b38971d..fd5e7cac 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -199,19 +199,6 @@ func buildSilentRepliesSection() []string { } } -func buildHeartbeatsSection() []string { - return []string{ - "## Heartbeats", - "", - "If you receive a heartbeat poll and there is nothing that needs attention, reply exactly:", - "HEARTBEAT_OK", - "", - "GoClaw treats a leading/trailing \"HEARTBEAT_OK\" as a heartbeat ack (and may discard it).", - "If something needs attention, do NOT include \"HEARTBEAT_OK\"; reply with the alert text instead.", - "", - } -} - func buildSpawnSection() []string { return []string{ "## Sub-Agent Spawning", diff --git a/internal/bootstrap/files.go b/internal/bootstrap/files.go index a18a2eba..43457ec5 100644 --- a/internal/bootstrap/files.go +++ b/internal/bootstrap/files.go @@ -8,7 +8,6 @@ // USER.md — user profile // IDENTITY.md— agent name, emoji, creature, vibe // TOOLS.md — local tool notes -// HEARTBEAT.md— periodic check tasks // BOOTSTRAP.md— first-run ritual (deleted after completion) // MEMORY.md — long-term curated memory package bootstrap @@ -27,7 +26,6 @@ const ( IdentityFile = "IDENTITY.md" UserFile = "USER.md" UserPredefinedFile = "USER_PREDEFINED.md" - HeartbeatFile = "HEARTBEAT.md" BootstrapFile = "BOOTSTRAP.md" DelegationFile = "DELEGATION.md" TeamFile = "TEAM.md" @@ -44,7 +42,6 @@ var standardFiles = []string{ ToolsFile, IdentityFile, UserFile, - HeartbeatFile, BootstrapFile, } diff --git a/internal/bootstrap/seed.go b/internal/bootstrap/seed.go index 32113f5b..4202075f 100644 --- a/internal/bootstrap/seed.go +++ b/internal/bootstrap/seed.go @@ -18,7 +18,6 @@ var templateFiles = []string{ ToolsFile, IdentityFile, UserFile, - HeartbeatFile, } // ReadTemplate returns the content of an embedded template file. diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index a785a347..e47f0546 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -41,10 +41,8 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. if name == UserFile { continue } - // TOOLS.md and HEARTBEAT.md are only useful in standalone mode. - // TOOLS.md: local tool notes (camera, SSH, device names). - // HEARTBEAT.md: replaced by cron jobs in managed mode. - if name == ToolsFile || name == HeartbeatFile { + // TOOLS.md: local tool notes (camera, SSH, device names) — not applicable. + if name == ToolsFile { continue } if hasContent[name] { @@ -83,7 +81,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. } // userSeedFilesOpen is the full set of files seeded per-user for open agents. -// TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode. +// TOOLS.md excluded — not applicable. var userSeedFilesOpen = []string{ AgentsFile, SoulFile, diff --git a/internal/bootstrap/templates/HEARTBEAT.md b/internal/bootstrap/templates/HEARTBEAT.md deleted file mode 100644 index d85d83d0..00000000 --- a/internal/bootstrap/templates/HEARTBEAT.md +++ /dev/null @@ -1,5 +0,0 @@ -# HEARTBEAT.md - -# Keep this file empty (or with only comments) to skip heartbeat API calls. - -# Add tasks below when you want the agent to check something periodically. diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 00000000..d4bc1c54 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,25 @@ +package cache + +import ( + "context" + "time" +) + +// Cache is a generic key-value cache interface. +// Implementations: InMemoryCache (this package), future: Redis, Memcache. +type Cache[V any] interface { + // Get retrieves a value by key. Returns the value and true if found, zero value and false if not. + Get(ctx context.Context, key string) (V, bool) + + // Set stores a value with an optional TTL. If ttl is 0, the entry never expires. + Set(ctx context.Context, key string, value V, ttl time.Duration) + + // Delete removes a single key. + Delete(ctx context.Context, key string) + + // DeleteByPrefix removes all keys matching the given prefix. + DeleteByPrefix(ctx context.Context, prefix string) + + // Clear removes all entries. + Clear(ctx context.Context) +} diff --git a/internal/cache/memory.go b/internal/cache/memory.go new file mode 100644 index 00000000..0857596d --- /dev/null +++ b/internal/cache/memory.go @@ -0,0 +1,71 @@ +package cache + +import ( + "context" + "strings" + "sync" + "time" +) + +// entry wraps a cached value with expiration metadata. +type entry[V any] struct { + value V + expiresAt time.Time // zero means no expiry +} + +func (e entry[V]) expired() bool { + return !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) +} + +// InMemoryCache is a thread-safe in-memory Cache implementation with TTL support. +type InMemoryCache[V any] struct { + data sync.Map +} + +// NewInMemoryCache creates a new in-memory cache. +func NewInMemoryCache[V any]() *InMemoryCache[V] { + return &InMemoryCache[V]{} +} + +func (c *InMemoryCache[V]) Get(_ context.Context, key string) (V, bool) { + raw, ok := c.data.Load(key) + if !ok { + var zero V + return zero, false + } + e := raw.(entry[V]) + if e.expired() { + c.data.Delete(key) + var zero V + return zero, false + } + return e.value, true +} + +func (c *InMemoryCache[V]) Set(_ context.Context, key string, value V, ttl time.Duration) { + e := entry[V]{value: value} + if ttl > 0 { + e.expiresAt = time.Now().Add(ttl) + } + c.data.Store(key, e) +} + +func (c *InMemoryCache[V]) Delete(_ context.Context, key string) { + c.data.Delete(key) +} + +func (c *InMemoryCache[V]) DeleteByPrefix(_ context.Context, prefix string) { + c.data.Range(func(key, _ any) bool { + if k, ok := key.(string); ok && strings.HasPrefix(k, prefix) { + c.data.Delete(key) + } + return true + }) +} + +func (c *InMemoryCache[V]) Clear(_ context.Context) { + c.data.Range(func(key, _ any) bool { + c.data.Delete(key) + return true + }) +} diff --git a/internal/cache/memory_test.go b/internal/cache/memory_test.go new file mode 100644 index 00000000..44f297fd --- /dev/null +++ b/internal/cache/memory_test.go @@ -0,0 +1,92 @@ +package cache + +import ( + "context" + "testing" + "time" +) + +func TestInMemoryCache_GetSet(t *testing.T) { + c := NewInMemoryCache[string]() + ctx := context.Background() + + c.Set(ctx, "hello", "world", 0) + val, ok := c.Get(ctx, "hello") + if !ok { + t.Fatal("expected key to exist") + } + if val != "world" { + t.Fatalf("expected 'world', got %q", val) + } +} + +func TestInMemoryCache_TTLExpiry(t *testing.T) { + c := NewInMemoryCache[int]() + ctx := context.Background() + + c.Set(ctx, "key", 42, 20*time.Millisecond) + + // Should be present immediately + val, ok := c.Get(ctx, "key") + if !ok || val != 42 { + t.Fatal("expected key to be present before TTL expiry") + } + + time.Sleep(40 * time.Millisecond) + + _, ok = c.Get(ctx, "key") + if ok { + t.Fatal("expected key to be expired after TTL") + } +} + +func TestInMemoryCache_Delete(t *testing.T) { + c := NewInMemoryCache[string]() + ctx := context.Background() + + c.Set(ctx, "k", "v", 0) + c.Delete(ctx, "k") + + _, ok := c.Get(ctx, "k") + if ok { + t.Fatal("expected key to be deleted") + } +} + +func TestInMemoryCache_DeleteByPrefix(t *testing.T) { + c := NewInMemoryCache[string]() + ctx := context.Background() + + c.Set(ctx, "agent:1:files", "a", 0) + c.Set(ctx, "agent:1:meta", "b", 0) + c.Set(ctx, "agent:2:files", "c", 0) + + c.DeleteByPrefix(ctx, "agent:1:") + + if _, ok := c.Get(ctx, "agent:1:files"); ok { + t.Error("agent:1:files should be deleted") + } + if _, ok := c.Get(ctx, "agent:1:meta"); ok { + t.Error("agent:1:meta should be deleted") + } + if _, ok := c.Get(ctx, "agent:2:files"); !ok { + t.Error("agent:2:files should still exist") + } +} + +func TestInMemoryCache_Clear(t *testing.T) { + c := NewInMemoryCache[int]() + ctx := context.Background() + + c.Set(ctx, "a", 1, 0) + c.Set(ctx, "b", 2, 0) + c.Set(ctx, "c", 3, 0) + + c.Clear(ctx) + + for _, k := range []string{"a", "b", "c"} { + if _, ok := c.Get(ctx, k); ok { + t.Errorf("key %q should be cleared", k) + } + } +} diff --git a/internal/channels/quota.go b/internal/channels/quota.go index 02720307..f4a53bd2 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -26,7 +26,7 @@ type quotaCounts struct { // QuotaChecker enforces per-user/group request quotas by counting top-level // traces in the database. Results are cached in-memory for cacheTTL. -// Managed mode only — nil-safe (standalone mode passes nil). +// Nil-safe (nil means quota not configured). type QuotaChecker struct { db *sql.DB config config.QuotaConfig diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 37812696..64c25baa 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -24,8 +24,8 @@ type Channel struct { bot *telego.Bot config config.TelegramConfig pairingService store.PairingStore - agentStore store.AgentStore // for group file writer management (nil in standalone) - teamStore store.TeamStore // for /tasks, /task_detail commands (nil in standalone) + agentStore store.AgentStore // for group file writer management (nil if not configured) + teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured) placeholders sync.Map // localKey string → messageID int stopThinking sync.Map // localKey string → *thinkingCancel typingCtrls sync.Map // localKey string → *typing.Controller diff --git a/internal/channels/zalo/personal/auth.go b/internal/channels/zalo/personal/auth.go index 460517ef..9a9a3f3d 100644 --- a/internal/channels/zalo/personal/auth.go +++ b/internal/channels/zalo/personal/auth.go @@ -17,7 +17,7 @@ import ( func (c *Channel) authenticate(ctx context.Context) (*protocol.Session, error) { sess := protocol.NewSession() - // 1. Preloaded credentials (managed mode: from factory). + // 1. Preloaded credentials (from DB via factory). if c.preloadedCreds != nil { slog.Info("zalo_personal: attempting login with preloaded credentials") if err := protocol.LoginWithCredentials(ctx, sess, *c.preloadedCreds); err != nil { @@ -26,7 +26,7 @@ func (c *Channel) authenticate(ctx context.Context) (*protocol.Session, error) { return sess, nil } - // 2. Saved file credentials (standalone). + // 2. Saved file credentials (config-based). credPath := c.resolveCredentialsPath() if cred := loadCredentials(credPath); cred != nil { slog.Info("zalo_personal: attempting login with saved credentials", "path", credPath) @@ -56,7 +56,7 @@ func (c *Channel) authenticate(ctx context.Context) (*protocol.Session, error) { return sess, nil } -// SetPreloadedCredentials sets credentials from DB (managed mode factory). +// SetPreloadedCredentials sets credentials from DB factory. func (c *Channel) SetPreloadedCredentials(cred *protocol.Credentials) { c.preloadedCreds = cred } diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index 77a1349d..5f0945bb 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -42,7 +42,7 @@ type Channel struct { sess *protocol.Session listener *protocol.Listener - // Pre-loaded credentials (managed mode: from DB, standalone: from file or QR). + // Pre-loaded credentials (from DB or from file/QR as fallback). preloadedCreds *protocol.Credentials requireMention bool diff --git a/internal/channels/zalo/personal/factory.go b/internal/channels/zalo/personal/factory.go index 3cc73ef5..a31fa566 100644 --- a/internal/channels/zalo/personal/factory.go +++ b/internal/channels/zalo/personal/factory.go @@ -27,7 +27,7 @@ type zaloInstanceConfig struct { AllowFrom []string `json:"allow_from,omitempty"` } -// Factory creates a Zalo Personal channel from DB instance data (managed mode). +// Factory creates a Zalo Personal channel from DB instance data. // Does NOT trigger QR login — credentials must be provided. func Factory(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { diff --git a/internal/config/config.go b/internal/config/config.go index fb136861..ff23c4fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,16 +65,10 @@ type TailscaleConfig struct { EnableTLS bool `json:"enable_tls,omitempty"` // use ListenTLS for auto HTTPS certs } -// DatabaseConfig configures Postgres for managed mode. +// DatabaseConfig configures the PostgreSQL connection. // PostgresDSN is NEVER read from config.json (secret) — only from env GOCLAW_POSTGRES_DSN. type DatabaseConfig struct { - PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only - Mode string `json:"mode,omitempty"` // "standalone" (default) or "managed" -} - -// IsManagedMode returns true if the gateway is running in managed (multi-tenant) mode. -func (c *Config) IsManagedMode() bool { - return c.Database.Mode == "managed" && c.Database.PostgresDSN != "" + PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only } // SkillsConfig configures the skills storage system. @@ -126,8 +120,6 @@ type AgentDefaults struct { Memory *MemoryConfig `json:"memory,omitempty"` Compaction *CompactionConfig `json:"compaction,omitempty"` ContextPruning *ContextPruningConfig `json:"contextPruning,omitempty"` - Heartbeat *HeartbeatConfig `json:"heartbeat,omitempty"` - // Bootstrap context truncation limits (matching TS bootstrapMaxChars / bootstrapTotalMaxChars) BootstrapMaxChars int `json:"bootstrapMaxChars,omitempty"` // per-file max before truncation (default 20000) BootstrapTotalMaxChars int `json:"bootstrapTotalMaxChars,omitempty"` // total budget across all files (default 24000) @@ -178,26 +170,6 @@ type ContextPruningHardClear struct { Placeholder string `json:"placeholder,omitempty"` // replacement text (default "[Old tool result content cleared]") } -// HeartbeatConfig configures periodic agent heartbeats. -// Matching TS agents.defaults.heartbeat. -type HeartbeatConfig struct { - Every string `json:"every,omitempty"` // duration string: "30m", "1h", "0m"=disabled (default "30m") - ActiveHours *ActiveHoursConfig `json:"activeHours,omitempty"` // restrict to time window - Model string `json:"model,omitempty"` // optional model override - Session string `json:"session,omitempty"` // "main" (default) or explicit session key - Target string `json:"target,omitempty"` // "last" (default), "none", or channel ID - To string `json:"to,omitempty"` // optional recipient override (chat ID) - Prompt string `json:"prompt,omitempty"` // custom heartbeat prompt - AckMaxChars int `json:"ackMaxChars,omitempty"` // max chars after HEARTBEAT_OK before dropping (default 300) -} - -// ActiveHoursConfig restricts heartbeats to a time window. -type ActiveHoursConfig struct { - Start string `json:"start,omitempty"` // "HH:MM" inclusive - End string `json:"end,omitempty"` // "HH:MM" exclusive - Timezone string `json:"timezone,omitempty"` // IANA timezone (default: local) -} - // MemoryConfig configures the agent memory system (SQLite + FTS5 + optional embeddings). // Matching TS agents.defaults.memory. type MemoryConfig struct { diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 287395d3..a16cb195 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -112,7 +112,7 @@ type ZaloPersonalConfig struct { DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) - CredentialsPath string `json:"credentials_path,omitempty"` // path to saved cookies JSON (standalone) + CredentialsPath string `json:"credentials_path,omitempty"` // path to saved cookies JSON } type FeishuConfig struct { @@ -189,7 +189,7 @@ type QuotaWindow struct { // IsZero returns true if no limits are set. func (w QuotaWindow) IsZero() bool { return w.Hour == 0 && w.Day == 0 && w.Week == 0 } -// QuotaConfig configures per-user/group request quotas (managed mode only). +// QuotaConfig configures per-user/group request quotas. // Config merge priority: Groups > Channels > Providers > Default. type QuotaConfig struct { Enabled bool `json:"enabled"` @@ -210,7 +210,7 @@ type GatewayConfig struct { RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled) - Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas (managed mode only) + Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas } // ToolsConfig controls tool availability, policy, and web search. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 2514b619..f9aea804 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strconv" @@ -160,7 +161,11 @@ func (c *Config) applyEnvOverrides() { // Database envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN) - envStr("GOCLAW_MODE", &c.Database.Mode) + + // Deprecation warning for GOCLAW_MODE (removed — PostgreSQL is always active) + if v := os.Getenv("GOCLAW_MODE"); v != "" { + slog.Warn("GOCLAW_MODE is deprecated; managed mode is now the only mode", "value", v) + } // Telemetry envStr("GOCLAW_TELEMETRY_ENDPOINT", &c.Telemetry.Endpoint) @@ -240,7 +245,7 @@ func (c *Config) applyEnvOverrides() { // src/config/defaults.ts. // // Go port does not have OAuth vs API-key distinction — we always treat it as -// API-key mode (heartbeat 30m). +// API-key mode. func (c *Config) applyContextPruningDefaults() { // Only apply when Anthropic is configured. if c.Providers.Anthropic.APIKey == "" { diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 8dd74ae9..b49f3e24 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -110,8 +110,8 @@ func (c *Config) StripSecrets() { } // StripMaskedSecrets strips only fields that still contain the mask value "***". -// Real values (user-entered via UI) are preserved. Used in standalone mode -// so that secrets entered via the config UI persist in config.json. +// Real values (user-entered via UI) are preserved, so that secrets entered +// via the config UI persist in config.json. func (c *Config) StripMaskedSecrets() { stripIfMasked := func(s *string) { if *s == secretMask { @@ -181,7 +181,7 @@ func (c *Config) ApplyDBSecrets(secrets map[string]string) { } // ExtractDBSecrets returns the config_secrets key-value pairs from the config. -// Used by managed mode to save secrets to the config_secrets table. +// Saves secrets to the config_secrets table. func (c *Config) ExtractDBSecrets() map[string]string { secrets := make(map[string]string) diff --git a/internal/gateway/log_tee.go b/internal/gateway/log_tee.go new file mode 100644 index 00000000..490ab6be --- /dev/null +++ b/internal/gateway/log_tee.go @@ -0,0 +1,121 @@ +package gateway + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// LogTee is a slog.Handler that forwards log records to subscribed WS clients +// while delegating to an underlying handler for normal output. +type LogTee struct { + inner slog.Handler + + mu sync.RWMutex + clients map[string]*Client // client ID → client +} + +// NewLogTee wraps an existing slog.Handler so log records are also forwarded +// to any WebSocket clients that have started log tailing. +func NewLogTee(inner slog.Handler) *LogTee { + return &LogTee{ + inner: inner, + clients: make(map[string]*Client), + } +} + +func (t *LogTee) Enabled(ctx context.Context, level slog.Level) bool { + return t.inner.Enabled(ctx, level) +} + +func (t *LogTee) Handle(ctx context.Context, r slog.Record) error { + // Forward to subscribers (non-blocking). + t.mu.RLock() + n := len(t.clients) + t.mu.RUnlock() + + if n > 0 { + entry := map[string]interface{}{ + "timestamp": r.Time.UnixMilli(), + "level": levelName(r.Level), + "message": r.Message, + } + + // Collect attributes into source hint. + var src string + r.Attrs(func(a slog.Attr) bool { + if a.Key == "component" || a.Key == "source" || a.Key == "module" { + src = a.Value.String() + return false + } + return true + }) + if src != "" { + entry["source"] = src + } + + evt := protocol.NewEvent("log", entry) + + t.mu.RLock() + for _, c := range t.clients { + c.SendEvent(*evt) + } + t.mu.RUnlock() + } + + return t.inner.Handle(ctx, r) +} + +func (t *LogTee) WithAttrs(attrs []slog.Attr) slog.Handler { + return &LogTee{ + inner: t.inner.WithAttrs(attrs), + clients: t.clients, + mu: t.mu, + } +} + +func (t *LogTee) WithGroup(name string) slog.Handler { + return &LogTee{ + inner: t.inner.WithGroup(name), + clients: t.clients, + mu: t.mu, + } +} + +// Subscribe adds a client to the log tailing set. +func (t *LogTee) Subscribe(client *Client) { + t.mu.Lock() + t.clients[client.ID()] = client + t.mu.Unlock() + + // Send an initial log entry so the client knows tailing started. + client.SendEvent(*protocol.NewEvent("log", map[string]interface{}{ + "timestamp": time.Now().UnixMilli(), + "level": "info", + "message": "Log tailing started", + "source": "gateway", + })) +} + +// Unsubscribe removes a client from the log tailing set. +func (t *LogTee) Unsubscribe(clientID string) { + t.mu.Lock() + delete(t.clients, clientID) + t.mu.Unlock() +} + +func levelName(l slog.Level) string { + switch { + case l >= slog.LevelError: + return "error" + case l >= slog.LevelWarn: + return "warn" + case l >= slog.LevelInfo: + return "info" + default: + return "debug" + } +} diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go index cb49ff19..cb77d397 100644 --- a/internal/gateway/methods/agent_links.go +++ b/internal/gateway/methods/agent_links.go @@ -41,7 +41,7 @@ type linksListParams struct { func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.linkStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured")) return } @@ -106,7 +106,7 @@ type linksCreateParams struct { func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.linkStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured")) return } @@ -219,7 +219,7 @@ type linksUpdateParams struct { func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.linkStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured")) return } @@ -313,7 +313,7 @@ type linksDeleteParams struct { func (m *AgentLinksMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.linkStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured")) return } diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index 9d0b3565..f2d6c217 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -24,13 +24,12 @@ type AgentsMethods struct { cfg *config.Config cfgPath string workspace string - agentStore store.AgentStore // nil in standalone mode - interceptor *tools.ContextFileInterceptor // nil in standalone mode; invalidated on file writes - isManaged bool + agentStore store.AgentStore + interceptor *tools.ContextFileInterceptor // invalidated on file writes } -func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspace string, agentStore store.AgentStore, isManaged bool, interceptor *tools.ContextFileInterceptor) *AgentsMethods { - return &AgentsMethods{agents: agents, cfg: cfg, cfgPath: cfgPath, workspace: workspace, agentStore: agentStore, isManaged: isManaged, interceptor: interceptor} +func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspace string, agentStore store.AgentStore, interceptor *tools.ContextFileInterceptor) *AgentsMethods { + return &AgentsMethods{agents: agents, cfg: cfg, cfgPath: cfgPath, workspace: workspace, agentStore: agentStore, interceptor: interceptor} } // isOwnerUser checks if the given user ID is in the configured owner IDs. @@ -107,8 +106,7 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien } func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - // In managed mode, query the store so ALL accessible agents appear (not just router-cached ones). - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { userID := client.UserID() if userID == "" { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user context required")) @@ -149,7 +147,7 @@ func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, return } - // Standalone mode: return router-cached agents. + // Fallback: return router-cached agents. infos := m.agents.ListInfo() client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ "agents": infos, @@ -167,7 +165,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, Avatar string `json:"avatar"` AgentType string `json:"agent_type"` // "open" (default) or "predefined" OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" - // Per-agent config overrides (managed mode only) + // Per-agent config overrides ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` @@ -204,7 +202,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, ws = config.ExpandHome(ws) } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: create agent in DB --- ctx := context.Background() @@ -259,7 +257,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, // Invalidate router cache so resolver re-loads from DB m.agents.InvalidateAgent(agentID) } else { - // --- Standalone mode: config.json + filesystem --- + // --- Fallback: config.json + filesystem --- if _, ok := m.cfg.Agents.List[agentID]; ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID)) return @@ -314,7 +312,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, Workspace string `json:"workspace"` Model string `json:"model"` Avatar string `json:"avatar"` - // Per-agent config overrides (managed mode only) + // Per-agent config overrides ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` @@ -332,7 +330,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, return } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: update agent in DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -397,7 +395,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, m.agents.InvalidateAgent(params.AgentID) } else { - // --- Standalone mode: config.json --- + // --- Fallback: config.json --- spec, ok := m.cfg.Agents.List[params.AgentID] if !ok { if params.AgentID != "default" { @@ -473,7 +471,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, var removedBindings int - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: delete from DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -495,7 +493,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, os.RemoveAll(ag.Workspace) } } else { - // --- Standalone mode: config.json --- + // --- Fallback: config.json --- spec, ok := m.cfg.Agents.List[params.AgentID] if !ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index a4a84c4a..0f81cc0b 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -126,7 +126,7 @@ func nullClient() *gateway.Client { return &gateway.Client{} } -// newManagedMethods returns AgentsMethods wired for managed mode with the given stub store. +// newManagedMethods returns AgentsMethods wired with the given stub store. func newManagedMethods(t *testing.T, stub store.AgentStore) *AgentsMethods { t.Helper() return &AgentsMethods{ @@ -134,7 +134,6 @@ func newManagedMethods(t *testing.T, stub store.AgentStore) *AgentsMethods { cfg: minimalConfig(), workspace: t.TempDir(), agentStore: stub, - isManaged: true, } } diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go index 1afdc930..b11c2bb0 100644 --- a/internal/gateway/methods/agents_files.go +++ b/internal/gateway/methods/agents_files.go @@ -14,7 +14,7 @@ import ( ) // allowedAgentFiles is the list of files exposed via agents.files.* RPCs. -// TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode. +// TOOLS.md excluded — not applicable. var allowedAgentFiles = []string{ bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.IdentityFile, bootstrap.UserFile, bootstrap.UserPredefinedFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile, @@ -32,7 +32,7 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien params.AgentID = "default" } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: list from DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -76,7 +76,7 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien return } - // --- Standalone mode: filesystem --- + // --- Fallback: filesystem --- ws := m.resolveWorkspace(params.AgentID) files := make([]map[string]interface{}, 0, len(allowedAgentFiles)) @@ -130,7 +130,7 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client return } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: read from DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -171,7 +171,7 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client return } - // --- Standalone mode: filesystem --- + // --- Fallback: filesystem --- ws := m.resolveWorkspace(params.AgentID) p := filepath.Join(ws, params.Name) @@ -228,7 +228,7 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client return } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: write to DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -261,7 +261,7 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client return } - // --- Standalone mode: filesystem --- + // --- Fallback: filesystem --- ws := m.resolveWorkspace(params.AgentID) os.MkdirAll(ws, 0755) p := filepath.Join(ws, params.Name) diff --git a/internal/gateway/methods/agents_identity.go b/internal/gateway/methods/agents_identity.go index 0e32e130..3876b22f 100644 --- a/internal/gateway/methods/agents_identity.go +++ b/internal/gateway/methods/agents_identity.go @@ -40,7 +40,7 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli "agentId": params.AgentID, } - if m.isManaged && m.agentStore != nil { + if m.agentStore != nil { // --- Managed mode: read identity from DB --- ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) @@ -70,7 +70,7 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli } } } else { - // --- Standalone mode: config + filesystem --- + // --- Fallback: config + filesystem --- result["name"] = m.cfg.ResolveDisplayName(params.AgentID) if spec, ok := m.cfg.Agents.List[params.AgentID]; ok && spec.Identity != nil { diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go index 68aef8e7..2d139028 100644 --- a/internal/gateway/methods/channel_instances.go +++ b/internal/gateway/methods/channel_instances.go @@ -13,7 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -// ChannelInstancesMethods handles channel instance CRUD via WebSocket RPC (managed mode). +// ChannelInstancesMethods handles channel instance CRUD via WebSocket RPC. type ChannelInstancesMethods struct { store store.ChannelInstanceStore msgBus *bus.MessageBus diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 095750a3..3843c465 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -18,12 +18,11 @@ import ( type ChatMethods struct { agents *agent.Router sessions store.SessionStore - isManaged bool rateLimiter *gateway.RateLimiter } -func NewChatMethods(agents *agent.Router, sess store.SessionStore, isManaged bool, rl *gateway.RateLimiter) *ChatMethods { - return &ChatMethods{agents: agents, sessions: sess, isManaged: isManaged, rateLimiter: rl} +func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter) *ChatMethods { + return &ChatMethods{agents: agents, sessions: sess, rateLimiter: rl} } // Register adds chat methods to the router. @@ -71,7 +70,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } userID := client.UserID() - if m.isManaged && userID == "" { + if userID == "" { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user_id is required in managed mode — provide it in the connect handshake")) return } diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index 916ab310..e84240ec 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -19,13 +19,12 @@ import ( type ConfigMethods struct { cfg *config.Config cfgPath string - managedMode bool - secretsStore store.ConfigSecretsStore // nil in standalone mode - eventBus bus.EventPublisher // nil-safe; broadcasts config change events + secretsStore store.ConfigSecretsStore + eventBus bus.EventPublisher // nil-safe; broadcasts config change events } -func NewConfigMethods(cfg *config.Config, cfgPath string, managedMode bool, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods { - return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, managedMode: managedMode, secretsStore: secretsStore, eventBus: eventBus} +func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods { + return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, secretsStore: secretsStore, eventBus: eventBus} } func (m *ConfigMethods) Register(router *gateway.MethodRouter) { @@ -72,15 +71,9 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, return } - // Branch on mode for secrets handling - if m.managedMode { - // Managed mode: extract secrets → save to config_secrets table, strip all from file - m.saveSecretsToStore(ctx, newCfg) - newCfg.StripSecrets() - } else { - // Standalone mode: only strip masked values, keep real values - newCfg.StripMaskedSecrets() - } + // Extract secrets → save to config_secrets table, strip all from file + m.saveSecretsToStore(ctx, newCfg) + newCfg.StripSecrets() // Save to disk if err := config.Save(m.cfgPath, newCfg); err != nil { @@ -90,7 +83,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, // Update in-memory config and restore secrets m.cfg.ReplaceFrom(newCfg) - if m.managedMode && m.secretsStore != nil { + if m.secretsStore != nil { if secrets, err := m.secretsStore.GetAll(ctx); err == nil { m.cfg.ApplyDBSecrets(secrets) } @@ -149,13 +142,9 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, return } - // Branch on mode for secrets handling - if m.managedMode { - m.saveSecretsToStore(ctx, merged) - merged.StripSecrets() - } else { - merged.StripMaskedSecrets() - } + // Extract secrets → save to config_secrets table, strip all from file + m.saveSecretsToStore(ctx, merged) + merged.StripSecrets() // Save to disk if err := config.Save(m.cfgPath, merged); err != nil { @@ -165,7 +154,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, // Update in-memory config and restore secrets m.cfg.ReplaceFrom(merged) - if m.managedMode && m.secretsStore != nil { + if m.secretsStore != nil { if secrets, err := m.secretsStore.GetAll(ctx); err == nil { m.cfg.ApplyDBSecrets(secrets) } @@ -228,7 +217,7 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, } // saveSecretsToStore extracts non-LLM/non-channel secrets from the config -// and persists them to the config_secrets table (managed mode only). +// and persists them to the config_secrets table. func (m *ConfigMethods) saveSecretsToStore(ctx context.Context, cfg *config.Config) { if m.secretsStore == nil { return diff --git a/internal/gateway/methods/logs.go b/internal/gateway/methods/logs.go new file mode 100644 index 00000000..00aa643d --- /dev/null +++ b/internal/gateway/methods/logs.go @@ -0,0 +1,50 @@ +package methods + +import ( + "context" + "encoding/json" + + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// LogsMethods handles logs.tail (start/stop live log tailing). +type LogsMethods struct { + logTee *gateway.LogTee +} + +func NewLogsMethods(logTee *gateway.LogTee) *LogsMethods { + return &LogsMethods{logTee: logTee} +} + +func (m *LogsMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodLogsTail, m.handleTail) +} + +func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + Action string `json:"action"` + } + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + switch params.Action { + case "start": + m.logTee.Subscribe(client) + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "status": "tailing", + })) + case "stop": + m.logTee.Unsubscribe(client.ID()) + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "status": "stopped", + })) + default: + client.SendResponse(protocol.NewErrorResponse( + req.ID, + protocol.ErrInvalidRequest, + "action must be 'start' or 'stop'", + )) + } +} diff --git a/internal/gateway/methods/quota_methods.go b/internal/gateway/methods/quota_methods.go index 81c42b25..511d9f33 100644 --- a/internal/gateway/methods/quota_methods.go +++ b/internal/gateway/methods/quota_methods.go @@ -10,7 +10,7 @@ import ( ) // QuotaMethods handles quota.usage — returns per-user quota consumption for the dashboard. -// Nil-safe: returns {enabled: false} when quotaChecker is nil (standalone mode). +// Nil-safe: returns {enabled: false} when quotaChecker is nil (quota not configured). // When checker is nil but db is available, still queries today's summary from traces. type QuotaMethods struct { checker *channels.QuotaChecker diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index b61e7ed4..056706da 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -12,7 +12,6 @@ import ( ) // SkillsMethods handles skills.list, skills.get, skills.update. -// Uses store.SkillStore interface — PGSkillStore in managed mode, FileSkillStore in standalone. type SkillsMethods struct { store store.SkillStore } @@ -99,7 +98,7 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client, // Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't) updater, ok := m.store.(skillUpdater) if !ok { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skills.update not supported in standalone mode")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skills.update not supported for file-based skills")) return } diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index faa222fd..f6c04450 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -52,7 +52,7 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) { func (m *TeamsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -81,7 +81,7 @@ type teamsCreateParams struct { func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go index 8b829e56..9ff7a09c 100644 --- a/internal/gateway/methods/teams_crud.go +++ b/internal/gateway/methods/teams_crud.go @@ -20,7 +20,7 @@ type teamsGetParams struct { func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -68,7 +68,7 @@ type teamsDeleteParams struct { func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -129,7 +129,7 @@ type teamsTaskListParams struct { func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -172,7 +172,7 @@ type teamsUpdateParams struct { func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -253,7 +253,7 @@ type teamsKnownUsersParams struct { func (m *TeamsMethods) handleKnownUsers(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go index 67166afe..6d153c45 100644 --- a/internal/gateway/methods/teams_members.go +++ b/internal/gateway/methods/teams_members.go @@ -22,7 +22,7 @@ type teamsAddMemberParams struct { func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } @@ -108,7 +108,7 @@ type teamsRemoveMemberParams struct { func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured")) return } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 717f4442..447bca56 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -168,10 +168,7 @@ func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *pr s := r.server uptimeMs := time.Since(s.startedAt).Milliseconds() - mode := "standalone" - if s.cfg.Database.Mode == "managed" { - mode = "managed" - } + mode := "managed" // Database status (real ping) dbStatus := "n/a" @@ -231,7 +228,7 @@ func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *pr sessionCount = len(r.server.sessions.List("")) } - // In managed mode, agents are lazily resolved — router only has loaded agents. + // Agents are lazily resolved — router only has loaded agents. // Query the DB store for the real total count. agentTotal := len(agents) if r.server.agentStore != nil { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 1615e521..c24c857d 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -33,16 +33,16 @@ type Server struct { policyEngine *permissions.PolicyEngine pairingService store.PairingStore - agentsHandler *httpapi.AgentsHandler // managed mode: agent CRUD API - skillsHandler *httpapi.SkillsHandler // managed mode: skill management API - tracesHandler *httpapi.TracesHandler // managed mode: LLM trace listing API - mcpHandler *httpapi.MCPHandler // managed mode: MCP server management API - customToolsHandler *httpapi.CustomToolsHandler // managed mode: custom tool CRUD API - channelInstancesHandler *httpapi.ChannelInstancesHandler // managed mode: channel instance CRUD API - providersHandler *httpapi.ProvidersHandler // managed mode: provider CRUD API - delegationsHandler *httpapi.DelegationsHandler // managed mode: delegation history API - builtinToolsHandler *httpapi.BuiltinToolsHandler // managed mode: builtin tool management API - agentStore store.AgentStore // managed mode: for context injection in tools_invoke + agentsHandler *httpapi.AgentsHandler // agent CRUD API + skillsHandler *httpapi.SkillsHandler // skill management API + tracesHandler *httpapi.TracesHandler // LLM trace listing API + mcpHandler *httpapi.MCPHandler // MCP server management API + customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API + channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API + providersHandler *httpapi.ProvidersHandler // provider CRUD API + delegationsHandler *httpapi.DelegationsHandler // delegation history API + builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API + agentStore store.AgentStore // for context injection in tools_invoke upgrader websocket.Upgrader rateLimiter *RateLimiter @@ -53,6 +53,8 @@ type Server struct { version string db interface{ PingContext(context.Context) error } // for health check DB ping + logTee *LogTee // optional; auto-unsubscribes clients on disconnect + httpServer *http.Server mux *http.ServeMux } @@ -269,33 +271,33 @@ func (s *Server) SetPolicyEngine(pe *permissions.PolicyEngine) { s.policyEngine // SetPairingService sets the pairing service for channel authentication. func (s *Server) SetPairingService(ps store.PairingStore) { s.pairingService = ps } -// SetAgentsHandler sets the managed-mode agent CRUD handler. +// SetAgentsHandler sets the agent CRUD handler. func (s *Server) SetAgentsHandler(h *httpapi.AgentsHandler) { s.agentsHandler = h } -// SetSkillsHandler sets the managed-mode skill management handler. +// SetSkillsHandler sets the skill management handler. func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.skillsHandler = h } -// SetTracesHandler sets the managed-mode LLM trace listing handler. +// SetTracesHandler sets the LLM trace listing handler. func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = h } -// SetMCPHandler sets the managed-mode MCP server management handler. +// SetMCPHandler sets the MCP server management handler. func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } -// SetCustomToolsHandler sets the managed-mode custom tool CRUD handler. +// SetCustomToolsHandler sets the custom tool CRUD handler. func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h } -// SetChannelInstancesHandler sets the managed-mode channel instance CRUD handler. +// SetChannelInstancesHandler sets the channel instance CRUD handler. func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) { s.channelInstancesHandler = h } -// SetProvidersHandler sets the managed-mode provider CRUD handler. +// SetProvidersHandler sets the provider CRUD handler. func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h } -// SetDelegationsHandler sets the managed-mode delegation history handler. +// SetDelegationsHandler sets the delegation history handler. func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h } -// SetBuiltinToolsHandler sets the managed-mode builtin tool management handler. +// SetBuiltinToolsHandler sets the builtin tool management handler. func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) { s.builtinToolsHandler = h } @@ -375,9 +377,17 @@ func (s *Server) unregisterClient(c *Client) { defer s.mu.Unlock() delete(s.clients, c.id) s.eventPub.Unsubscribe(c.id) + if s.logTee != nil { + s.logTee.Unsubscribe(c.id) + } slog.Info("client disconnected", "id", c.id) } +// SetLogTee attaches a LogTee so that disconnecting clients are auto-unsubscribed. +func (s *Server) SetLogTee(lt *LogTee) { + s.logTee = lt +} + // StartTestServer creates a listener on :0 (random port) and returns the // actual address and a start function. Used for integration tests. func StartTestServer(s *Server, ctx context.Context) (addr string, start func()) { diff --git a/internal/heartbeat/service.go b/internal/heartbeat/service.go deleted file mode 100644 index f96ea3c1..00000000 --- a/internal/heartbeat/service.go +++ /dev/null @@ -1,412 +0,0 @@ -// Package heartbeat provides a periodic background agent runner. -// Matching TS src/infra/heartbeat-runner.ts. -// -// The heartbeat wakes the agent at regular intervals so it can check on -// things (calendar, inbox, alerts) and surface anything that needs attention. -// If nothing needs attention the agent replies with HEARTBEAT_OK which is -// silently dropped. -package heartbeat - -import ( - "context" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/config" -) - -// Default heartbeat prompt matching TS. -const defaultPrompt = "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. " + - "Do not infer or repeat old tasks from prior chats. " + - "If nothing needs attention, reply HEARTBEAT_OK." - -const defaultInterval = 30 * time.Minute - -// DefaultInterval returns the default heartbeat interval (30m). -func DefaultInterval() time.Duration { return defaultInterval } -const defaultAckMaxChars = 300 -const heartbeatOKToken = "HEARTBEAT_OK" - -// AgentRunner is the callback the service uses to run an agent turn. -// It returns the agent's response text and an error. -type AgentRunner func(ctx context.Context, agentID, sessionKey, message, runID string) (string, error) - -// DeliveryTarget holds resolved delivery info for a heartbeat alert. -type DeliveryTarget struct { - Channel string - ChatID string -} - -// LastUsedResolver returns the last-used channel + chatID for an agent. -// Returns ("", "") if unknown. -type LastUsedResolver func(agentID string) (channel, chatID string) - -// Config holds resolved runtime config for the heartbeat service. -type Config struct { - AgentID string - Interval time.Duration - ActiveHours *config.ActiveHoursConfig - Model string // unused for now, reserved for model override - SessionKey string - Target string // "last", "none", or channel name - To string // explicit chat ID - Prompt string - AckMaxChars int - Workspace string // for HEARTBEAT.md detection -} - -// Service manages the periodic heartbeat loop. -type Service struct { - cfg Config - runner AgentRunner - msgBus *bus.MessageBus - lastUsed LastUsedResolver - mu sync.Mutex - running bool - cancel context.CancelFunc - lastContent string // dedup: last non-OK content - lastAlertAt time.Time // dedup: when last alert was sent -} - -// NewService creates a heartbeat service. -func NewService(cfg Config, runner AgentRunner, msgBus *bus.MessageBus, lastUsed LastUsedResolver) *Service { - if cfg.Interval <= 0 { - cfg.Interval = defaultInterval - } - if cfg.Prompt == "" { - cfg.Prompt = defaultPrompt - } - if cfg.AckMaxChars <= 0 { - cfg.AckMaxChars = defaultAckMaxChars - } - if cfg.SessionKey == "" { - cfg.SessionKey = fmt.Sprintf("agent:%s:heartbeat:main", cfg.AgentID) - } - if cfg.Target == "" { - cfg.Target = "last" - } - - return &Service{ - cfg: cfg, - runner: runner, - msgBus: msgBus, - lastUsed: lastUsed, - } -} - -// Start begins the heartbeat loop in a background goroutine. -func (s *Service) Start() { - s.mu.Lock() - defer s.mu.Unlock() - - if s.running { - return - } - - ctx, cancel := context.WithCancel(context.Background()) - s.cancel = cancel - s.running = true - - go s.loop(ctx) - slog.Info("heartbeat service started", - "agent", s.cfg.AgentID, - "interval", s.cfg.Interval, - "target", s.cfg.Target, - ) -} - -// Stop halts the heartbeat loop. -func (s *Service) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.running { - return - } - - s.cancel() - s.running = false - slog.Info("heartbeat service stopped", "agent", s.cfg.AgentID) -} - -// IsRunning returns whether the heartbeat loop is active. -func (s *Service) IsRunning() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.running -} - -// --- Internal loop --- - -func (s *Service) loop(ctx context.Context) { - // Initial delay: wait one full interval before first heartbeat - // (matching TS behavior — don't fire immediately on startup). - timer := time.NewTimer(s.cfg.Interval) - defer timer.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-timer.C: - s.tick(ctx) - timer.Reset(s.cfg.Interval) - } - } -} - -func (s *Service) tick(ctx context.Context) { - // Check active hours - if s.cfg.ActiveHours != nil && !isInActiveHours(s.cfg.ActiveHours) { - slog.Debug("heartbeat skipped: outside active hours", "agent", s.cfg.AgentID) - return - } - - // Check if HEARTBEAT.md is effectively empty - if s.isHeartbeatFileEmpty() { - slog.Debug("heartbeat skipped: HEARTBEAT.md empty", "agent", s.cfg.AgentID) - return - } - - // Run agent turn - runID := fmt.Sprintf("heartbeat-%s-%d", s.cfg.AgentID, time.Now().UnixMilli()) - reply, err := s.runner(ctx, s.cfg.AgentID, s.cfg.SessionKey, s.cfg.Prompt, runID) - if err != nil { - slog.Warn("heartbeat agent run failed", "agent", s.cfg.AgentID, "error", err) - return - } - - // Normalize response: strip HEARTBEAT_OK token - content, isOK := stripHeartbeatToken(reply, s.cfg.AckMaxChars) - - if isOK { - slog.Debug("heartbeat OK", "agent", s.cfg.AgentID) - return - } - - // Dedup: skip if same content within 24h - s.mu.Lock() - if content == s.lastContent && time.Since(s.lastAlertAt) < 24*time.Hour { - s.mu.Unlock() - slog.Debug("heartbeat dedup: same content within 24h", "agent", s.cfg.AgentID) - return - } - s.lastContent = content - s.lastAlertAt = time.Now() - s.mu.Unlock() - - // Deliver alert - s.deliver(content) -} - -// deliver sends the heartbeat alert to the configured target. -func (s *Service) deliver(content string) { - if s.cfg.Target == "none" { - slog.Info("heartbeat alert (target=none, not delivered)", - "agent", s.cfg.AgentID, - "preview", truncate(content, 100), - ) - return - } - - channel, chatID := s.resolveTarget() - if channel == "" || chatID == "" { - slog.Warn("heartbeat alert: no delivery target resolved", - "agent", s.cfg.AgentID, - "target", s.cfg.Target, - ) - return - } - - slog.Info("heartbeat alert delivered", - "agent", s.cfg.AgentID, - "channel", channel, - "chatID", chatID, - "preview", truncate(content, 100), - ) - - s.msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, - }) -} - -// resolveTarget determines where to deliver heartbeat alerts. -func (s *Service) resolveTarget() (channel, chatID string) { - // Explicit channel target - if s.cfg.Target != "" && s.cfg.Target != "last" && s.cfg.Target != "none" { - channel = s.cfg.Target - chatID = s.cfg.To - return - } - - // "last" — ask the resolver - if s.lastUsed != nil { - channel, chatID = s.lastUsed(s.cfg.AgentID) - } - - // Override chatID if explicitly set - if s.cfg.To != "" { - chatID = s.cfg.To - } - - return -} - -// isHeartbeatFileEmpty checks if HEARTBEAT.md exists and has meaningful content. -// Matching TS isHeartbeatContentEffectivelyEmpty(). -func (s *Service) isHeartbeatFileEmpty() bool { - if s.cfg.Workspace == "" { - return true - } - - path := filepath.Join(s.cfg.Workspace, "HEARTBEAT.md") - data, err := os.ReadFile(path) - if err != nil { - return true // file doesn't exist = empty - } - - return isEffectivelyEmpty(string(data)) -} - -// isEffectivelyEmpty returns true if content has no meaningful text -// (only whitespace, markdown headers, empty list items, comments). -// Matching TS isHeartbeatContentEffectivelyEmpty(). -func isEffectivelyEmpty(content string) bool { - for _, line := range strings.Split(content, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Skip markdown headers (# ...) - if strings.HasPrefix(line, "#") { - trimmed := strings.TrimLeft(line, "# ") - if trimmed == "" { - continue - } - // Header with text = not empty - return false - } - // Skip comments () - if strings.HasPrefix(line, "